Compare commits

...

6 Commits

Author SHA1 Message Date
m7amedez5511
1e4d9c5b59 update dashboard home 2026-08-30 16:18:54 +03:00
m7amedez5511
0658e59d9b edit location info 2026-08-23 15:34:18 +03:00
m7amedez5511
b27da3f6e8 solve middleware problem and navigation problem 2026-08-23 11:34:42 +03:00
m7amedez5511
c5e4ade2fb solve Hydration Mismatch problem 2026-08-19 18:34:03 +03:00
m7amedez5511
2d05d3446c solve problem tree rerender 2026-08-19 18:00:55 +03:00
m7amedez5511
55b03ad6f4 update user page and logo to be link also update profile page 2026-08-17 16:24:58 +03:00
37 changed files with 2979 additions and 913 deletions

View File

@@ -16,5 +16,5 @@ export default function ConditionalNavbar() {
const isDashboardRoute = pathname?.startsWith("/dashboard"); const isDashboardRoute = pathname?.startsWith("/dashboard");
if (user || isDashboardRoute) return null; if (user || isDashboardRoute) return null;
return <Navbar />; return "";
} }

View File

@@ -325,7 +325,7 @@ function SidebarContent({
justifyContent: compact ? "center" : "flex-start", justifyContent: compact ? "center" : "flex-start",
}} }}
> >
{compact ? <BrandIcon size={36} /> : <Logo white />} {compact ? <BrandIcon size={36} /> : <Logo white href="/dashboard" />}
</div> </div>
<nav <nav

View File

@@ -0,0 +1,74 @@
"use client";
import { useEffect } from "react";
// Some browser extensions (Bitdefender TrafficLight and similar) inject
// bis_skin_checked / bis_register attributes onto form elements before
// React hydrates the page. This causes a harmless but noisy hydration
// mismatch warning. This component strips those attributes after mount
// and keeps watching for re-injection, without disabling SSR/hydration.
export const WATCHED_ATTRS = [
"bis_skin_checked",
"bis_register",
"__processed_by_bitdefender__",
] as const;
export function ExtensionAttributeCleanup() {
useEffect(() => {
if (typeof document === "undefined" || typeof MutationObserver === "undefined") {
return;
}
const selector = WATCHED_ATTRS.map((attr) => `[${attr}]`).join(",");
function strip() {
const rootElements = [document.documentElement, document.body].filter(
(element): element is HTMLElement => element !== null,
);
rootElements.forEach((element) => {
WATCHED_ATTRS.forEach((attr) => element.removeAttribute(attr));
});
document.querySelectorAll(selector).forEach(el => {
WATCHED_ATTRS.forEach(attr => el.removeAttribute(attr));
});
}
let frameId: number | null = null;
function scheduleStrip() {
if (frameId !== null) {
return;
}
frameId = window.requestAnimationFrame(() => {
frameId = null;
strip();
});
}
// Initial cleanup right after hydration
strip();
// Extensions can re-inject the attribute after every re-render
// (e.g. after clicking Save, which re-renders the form), so keep
// watching instead of running once.
const observer = new MutationObserver(scheduleStrip);
observer.observe(document.documentElement, {
attributes: true,
subtree: true,
attributeFilter: [...WATCHED_ATTRS],
});
return () => {
observer.disconnect();
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
};
}, []);
// Renders nothing — side-effect-only component
return null;
}

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"
aria-live="polite"
style={{
position: "fixed",
bottom: 24,
left: "50%",
transform: "translateX(-50%)", transform: "translateX(-50%)",
zIndex: 9999, pointerEvents: "none", zIndex: 9999,
}}> pointerEvents: "none",
<div style={{ }}
display: "flex", alignItems: "center", gap: 10, >
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "0.75rem 1.25rem", padding: "0.75rem 1.25rem",
borderRadius: "var(--radius-full)", borderRadius: "var(--radius-full)",
background: ok ? "#065F46" : "#7F1D1D", background: ok ? "#065F46" : "#7F1D1D",
color: "#FFF", fontSize: 13, fontWeight: 600, color: "#FFF",
fontSize: 13,
fontWeight: 600,
boxShadow: "0 8px 32px rgba(0,0,0,.25)", boxShadow: "0 8px 32px rgba(0,0,0,.25)",
maxWidth: "90vw", whiteSpace: "nowrap", maxWidth: "90vw",
whiteSpace: "nowrap",
fontFamily: "var(--font-sans)", 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>
@@ -62,7 +93,8 @@ function CarCard({
// 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
style={{
height: 4, height: 4,
background: status.dot, background: status.dot,
borderRadius: "var(--radius-xl) var(--radius-xl) 0 0", 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={{
display: "inline-flex",
alignItems: "center",
gap: 5,
borderRadius: "var(--radius-full)", borderRadius: "var(--radius-full)",
border: `1px solid ${status.border}`, border: `1px solid ${status.border}`,
background: status.bg, background: status.bg,
padding: "0.2rem 0.625rem", padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 700, color: status.color, fontSize: 11,
whiteSpace: "nowrap", flexShrink: 0, fontWeight: 700,
}}> color: status.color,
<span style={{ width: 6, height: 6, borderRadius: "50%", background: status.dot }} /> 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
style={{
marginTop: "0.875rem", marginTop: "0.875rem",
background: "var(--color-surface-muted)", background: "var(--color-surface-muted)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
borderRadius: "var(--radius-md)", borderRadius: "var(--radius-md)",
padding: "0.5rem 0.75rem", padding: "0.5rem 0.75rem",
display: "flex", alignItems: "center", justifyContent: "space-between", display: "flex",
}}> alignItems: "center",
<span style={{ fontSize: 11, color: "var(--color-text-muted)", fontWeight: 600 }}>رقم اللوحة</span> justifyContent: "space-between",
<span style={{ }}
>
<span
style={{
fontSize: 11,
color: "var(--color-text-muted)",
fontWeight: 600,
}}
>
رقم اللوحة
</span>
<span
style={{
fontFamily: "var(--font-mono)", fontFamily: "var(--font-mono)",
fontSize: 14, fontWeight: 700, letterSpacing: "0.1em", fontSize: 14,
fontWeight: 700,
letterSpacing: "0.1em",
color: "var(--color-brand-700)", 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>
@@ -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) => {
notify({ type: "success", message: msg });
loadCars();
},
onError: (msg) => notify({ type: "error", message: msg }), onError: (msg) => notify({ type: "error", message: msg }),
onDeleted: (id) => { removeCar(id); setDeleteTarget(null); }, onDeleted: (id) => {
removeCar(id);
setDeleteTarget(null);
},
getEditTarget, getEditTarget,
}); });
@@ -234,9 +415,13 @@ 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 } =
useCarMaintenanceMutations({
carId: maintenanceTarget?.id ?? "", carId: maintenanceTarget?.id ?? "",
onSuccess: (msg) => { notify({ type: "success", message: msg }); loadCars(); }, onSuccess: (msg) => {
notify({ type: "success", message: msg });
loadCars();
},
onError: () => {}, onError: () => {},
onDeleted: () => {}, onDeleted: () => {},
getEditTarget: () => null, getEditTarget: () => null,
@@ -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
style={{
borderRadius: "var(--radius-xl)", borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", background: "var(--color-surface)",
padding: "1.5rem 2rem", padding: "1.5rem 2rem",
boxShadow: "var(--shadow-card)", 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
style={{
borderRadius: "var(--radius-xl)", borderRadius: "var(--radius-xl)",
border: "2px dashed var(--color-border)", border: "2px dashed var(--color-border)",
background: "var(--color-surface)", background: "var(--color-surface)",
padding: "5rem 2rem", padding: "5rem 2rem",
textAlign: "center", 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
style={{
display: "grid", display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
gap: "1rem", 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={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderRadius: "var(--radius-xl)", borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", background: "var(--color-surface)",
padding: "0.875rem 1.5rem", padding: "0.875rem 1.5rem",
boxShadow: "var(--shadow-card)", 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)",

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
style={{
borderRadius: "var(--radius-xl)", borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", background: "var(--color-surface)",
overflow: "hidden", overflow: "hidden",
boxShadow: "var(--shadow-card)", boxShadow: "var(--shadow-card)",
}}> }}
<div style={{ >
<div
style={{
padding: "0.875rem 1.5rem", padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)", borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)", background: "var(--color-surface-muted)",
}}> }}
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: 0 }}> >
<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,23 +74,49 @@ 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",
borderBottom: "1px solid var(--color-border)",
}}
>
<span
style={{
fontSize: 12,
color: "var(--color-text-muted)",
fontWeight: 600,
}}
>
{label}
</span>
<span
style={{
fontSize: 13, fontSize: 13,
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)", fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
color: warn ? "#D97706" : "var(--color-text-primary)", color: warn ? "#D97706" : "var(--color-text-primary)",
fontWeight: warn ? 600 : 400, fontWeight: warn ? 600 : 400,
maxWidth: "60%", textAlign: "left", wordBreak: "break-word", maxWidth: "60%",
}}> textAlign: "left",
{warn && value !== "—" ? "⚠ " : ""}{value} wordBreak: "break-word",
}}
>
{warn && value !== "—" ? "⚠ " : ""}
{value}
</span> </span>
</div> </div>
); );
@@ -79,16 +134,22 @@ export default function DriverDetailPage() {
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(
(type: "success" | "error", message: string) => {
setToast({ type, message }); setToast({ type, message });
setTimeout(() => setToast(null), 4000); 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,14 +437,24 @@ 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 />
@@ -248,27 +466,72 @@ export default function DriverDetailPage() {
</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="اسم المستخدم"
value={driver.userName ?? "—"}
mono
/>
<DetailRow
label="تاريخ الإضافة"
value={fmtDate(driver.createdAt)}
/>
<DetailRow label="آخر تحديث" value={fmtDate(driver.updatedAt)} /> <DetailRow label="آخر تحديث" value={fmtDate(driver.updatedAt)} />
</SectionCard> </SectionCard>
@@ -276,18 +539,51 @@ export default function DriverDetailPage() {
<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
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "1.25rem",
}}
>
<PhotoCard url={driver.photoUrl} label="صورة السائق" /> <PhotoCard url={driver.photoUrl} label="صورة السائق" />
<PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" /> <PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" />
<PhotoCard url={driver.driverCardPhotoUrl} 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)}

View File

@@ -5,16 +5,30 @@ 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();
@@ -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
style={{
borderRadius: "var(--radius-xl)", borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", background: "var(--color-surface)",
padding: "1.5rem 2rem", padding: "1.5rem 2rem",
boxShadow: "var(--shadow-card)", 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,9 +300,7 @@ 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

@@ -6,7 +6,6 @@ export default function ProfilePage() {
const { user, loading, error } = useCurrentUser(); const { user, loading, error } = useCurrentUser();
if (loading) { if (loading) {
// حالة تحميل بسيطة تتماشى مع سبينر الصور اللي عملتها قبل كده في الصفحات التانية
return ( return (
<div className="flex min-h-[60vh] items-center justify-center"> <div className="flex min-h-[60vh] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" /> <div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-600 border-t-transparent" />
@@ -27,12 +26,10 @@ export default function ProfilePage() {
{ label: "اسم المستخدم", value: user.userName ?? "—" }, { label: "اسم المستخدم", value: user.userName ?? "—" },
{ label: "البريد الإلكتروني", value: user.email ?? "—" }, { label: "البريد الإلكتروني", value: user.email ?? "—" },
{ label: "رقم الهاتف", value: user.phone }, { label: "رقم الهاتف", value: user.phone },
{ label: "الحالة", value: user.isActive ? "نشط" : "غير نشط" },
{ label: "تاريخ الإنشاء", value: new Date(user.createdAt).toLocaleDateString("ar-SA") }, { label: "تاريخ الإنشاء", value: new Date(user.createdAt).toLocaleDateString("ar-SA") },
{ label: "آخر تحديث", value: new Date(user.updatedAt).toLocaleDateString("ar-SA") }, { label: "آخر تحديث", value: new Date(user.updatedAt).toLocaleDateString("ar-SA") },
]; ];
// تجميع الصلاحيات حسب الموديول عشان تبقى منظمة وسهلة القراءة
const permissionsByModule = (user.role?.permissions ?? []).reduce<Record<string, string[]>>( const permissionsByModule = (user.role?.permissions ?? []).reduce<Record<string, string[]>>(
(acc, entry) => { (acc, entry) => {
const mod = entry.permission.module || "أخرى"; const mod = entry.permission.module || "أخرى";
@@ -43,24 +40,43 @@ export default function ProfilePage() {
{}, {},
); );
const modules = Object.keys(permissionsByModule); const modules = Object.keys(permissionsByModule);
const totalPermissions = Object.values(permissionsByModule).reduce((n, arr) => n + arr.length, 0);
return ( return (
<div className="mx-auto max-w-2xl space-y-6 px-4 py-10" dir="rtl"> <div className="min-h-screen bg-slate-50" dir="rtl">
{/* بطاقة البيانات الأساسية */} {/* top bar: user info */}
<div className="rounded-2xl bg-white p-6 shadow-md"> <div className="border-b border-slate-200 bg-white">
<div className="mb-6 flex flex-col items-center gap-3"> <div className="mx-auto flex w-full max-w-6xl flex-col items-center gap-4 px-6 py-10 sm:flex-row sm:items-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-blue-50 text-2xl font-semibold text-blue-600 ring-2 ring-blue-100"> <div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-blue-600 text-xl font-semibold text-white">
{user.name?.trim().charAt(0) ?? "?"} {user.name?.trim().charAt(0) ?? "?"}
</div> </div>
<h1 className="text-base font-semibold text-slate-900">{user.name}</h1> <div className="flex-1 text-center sm:text-right">
<h1 className="text-lg font-semibold text-slate-900">{user.name}</h1>
<p className="mt-0.5 text-sm text-slate-500">{user.email ?? user.phone}</p>
</div>
<div className="flex items-center gap-2">
{user.role?.name && ( {user.role?.name && (
<span className="rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700"> <span className="rounded-full bg-blue-50 px-3 py-1 text-xs font-medium text-blue-700">
{user.role.name} {user.role.name}
</span> </span>
)} )}
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
user.isActive ? "bg-emerald-50 text-emerald-700" : "bg-slate-100 text-slate-500"
}`}
>
{user.isActive ? "نشط" : "غير نشط"}
</span>
</div>
</div>
</div> </div>
{/* عرض البيانات بشكل read-only بدون أي فورم أو زرار تعديل */} {/* main content, full width */}
<div className="mx-auto w-full max-w-6xl px-6 py-8">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* account details */}
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-100 lg:col-span-1">
<h2 className="mb-4 text-sm font-semibold text-slate-900">بيانات الحساب</h2>
<dl className="divide-y divide-slate-100"> <dl className="divide-y divide-slate-100">
{fields.map((f) => ( {fields.map((f) => (
<div key={f.label} className="flex items-center justify-between py-2.5 text-[13px]"> <div key={f.label} className="flex items-center justify-between py-2.5 text-[13px]">
@@ -71,53 +87,51 @@ export default function ProfilePage() {
</dl> </dl>
</div> </div>
{/* بطاقة الدور والصلاحيات */} {/* role and permissions */}
{user.role && ( <div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-slate-100 lg:col-span-2">
<div className="rounded-2xl bg-white p-6 shadow-md"> <div className="mb-4 flex items-center justify-between">
<div className="mb-4"> <div>
<h2 className="text-sm font-semibold text-slate-900">الدور والصلاحيات</h2> <h2 className="text-sm font-semibold text-slate-900">الدور والصلاحيات</h2>
{user.role.description && ( {user.role?.description && (
<p className="mt-1 text-[13px] text-slate-500">{user.role.description}</p> <p className="mt-1 text-[13px] text-slate-500">{user.role.description}</p>
)} )}
</div> </div>
{modules.length > 0 && (
<span className="rounded-full bg-slate-100 px-3 py-1 text-[11px] font-medium text-slate-500">
{totalPermissions} صلاحية
</span>
)}
</div>
{modules.length === 0 ? ( {modules.length === 0 ? (
<p className="text-[13px] text-slate-400">لا توجد صلاحيات مسجّلة لهذا الدور.</p> <p className="text-[13px] text-slate-400">لا توجد صلاحيات مسجّلة لهذا الدور.</p>
) : ( ) : (
<div className="space-y-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{modules.map((mod) => ( {modules.map((mod) => (
<details <div key={mod} className="rounded-xl border border-slate-100 bg-slate-50/60 p-3">
key={mod} <div className="mb-2 flex items-center justify-between">
className="group rounded-lg border border-slate-100 open:bg-slate-50" <span className="text-[13px] font-medium text-slate-700">{mod}</span>
open <span className="rounded-full bg-white px-2 py-0.5 text-[11px] font-normal text-slate-500 ring-1 ring-slate-100">
>
<summary className="flex cursor-pointer list-none items-center justify-between px-3 py-2.5 text-[13px] font-medium text-slate-700">
<span className="flex items-center gap-2">
{mod}
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-normal text-slate-500">
{permissionsByModule[mod].length} {permissionsByModule[mod].length}
</span> </span>
</span> </div>
<span className="text-slate-400 transition-transform group-open:rotate-180"> <div className="flex flex-wrap gap-1.5">
</span>
</summary>
<div className="flex flex-wrap gap-2 px-3 pb-3 pt-1">
{permissionsByModule[mod].map((name) => ( {permissionsByModule[mod].map((name) => (
<span <span
key={name} key={name}
className="rounded-md bg-blue-50 px-2.5 py-1 text-[12px] font-medium text-blue-700" className="rounded-md bg-blue-50 px-2 py-1 text-[11px] font-medium text-blue-700"
> >
{name} {name}
</span> </span>
))} ))}
</div> </div>
</details> </div>
))} ))}
</div> </div>
)} )}
</div> </div>
)} </div>
</div>
</div> </div>
); );
} }

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>
@@ -189,7 +217,9 @@ export default function TripDetailPage() {
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>
@@ -461,7 +599,9 @@ export default function TripDetailPage() {
<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 */}
@@ -469,12 +609,31 @@ export default function TripDetailPage() {
<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
label="اسم المستخدم"
value={trip.driver.userName ?? "—"}
mono
/>
<DetailRow label="البريد" value={trip.driver.email ?? "—"} /> <DetailRow label="البريد" value={trip.driver.email ?? "—"} />
<DetailRow label="الجنسية" value={trip.driver.nationality ?? "—"} /> <DetailRow
<DetailRow label="رخصة القيادة" value={trip.driver.licenseNumber ?? "—"} mono /> label="الجنسية"
<DetailRow label="بطاقة السائق" value={trip.driver.driverCardNumber ?? "—"} mono /> value={trip.driver.nationality ?? "—"}
<DetailRow label="رقم GOSI" value={trip.driver.gosiNumber ?? "—"} mono /> />
<DetailRow
label="رخصة القيادة"
value={trip.driver.licenseNumber ?? "—"}
mono
/>
<DetailRow
label="بطاقة السائق"
value={trip.driver.driverCardNumber ?? "—"}
mono
/>
<DetailRow
label="رقم GOSI"
value={trip.driver.gosiNumber ?? "—"}
mono
/>
</SectionCard> </SectionCard>
)} )}
@@ -483,11 +642,22 @@ export default function TripDetailPage() {
<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
label="السنة"
value={trip.car.year != null ? String(trip.car.year) : "—"}
/>
<DetailRow label="اللون" value={trip.car.color ?? "—"} /> <DetailRow label="اللون" value={trip.car.color ?? "—"} />
<DetailRow label="رقم اللوحة" value={trip.car.plateNumber} mono /> <DetailRow label="رقم اللوحة" value={trip.car.plateNumber} mono />
<DetailRow label="حروف اللوحة" value={trip.car.plateLetters ?? "—"} mono /> <DetailRow
<DetailRow label="رقم التسجيل" value={trip.car.registrationNumber ?? "—"} mono /> label="حروف اللوحة"
value={trip.car.plateLetters ?? "—"}
mono
/>
<DetailRow
label="رقم التسجيل"
value={trip.car.registrationNumber ?? "—"}
mono
/>
</SectionCard> </SectionCard>
)} )}
@@ -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}

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="الأرشيف" />

View File

@@ -65,6 +65,11 @@ export default function UsersPage() {
{/* Create / Edit modal */} {/* Create / Edit modal */}
{formTarget !== false && ( {formTarget !== false && (
<UserFormModal <UserFormModal
// CHANGE: added key — forces a full remount when switching between
// "new" and different edit targets, so useForm's defaultValues
// (roleId/branchId included) always reflect the correct user
// instead of possibly reusing stale state from a previous instance.
key={formTarget === null ? "new" : formTarget.id}
editUser={formTarget} editUser={formTarget}
roles={roles} roles={roles}
branches={branches} branches={branches}

View File

@@ -130,12 +130,7 @@ export default function HomePage() {
> >
تسجيل الدخول للوحة التحكم تسجيل الدخول للوحة التحكم
</Link> </Link>
<Link
href="/how_work"
className="inline-flex h-11 items-center rounded-xl bg-white px-5 text-[13px] font-semibold text-blue-700 shadow-md"
>
كيف تعمل المنصة؟
</Link>
</div> </div>
</article> </article>
@@ -214,9 +209,7 @@ export default function HomePage() {
})} })}
</div> </div>
<div className="mt-6"> <div className="mt-6">
<Link href="/features/app" className="text-[13px] font-semibold text-blue-600 hover:text-blue-700">
عرض كل المميزات
</Link>
</div> </div>
</article> </article>
</section> </section>
@@ -273,12 +266,7 @@ export default function HomePage() {
> >
ابدأ الآن ابدأ الآن
</Link> </Link>
<Link
href="/prices"
className="inline-flex h-11 items-center rounded-xl border border-white/30 bg-white/10 px-6 text-[13px] font-semibold text-white backdrop-blur"
>
عرض الأسعار
</Link>
</div> </div>
</article> </article>
</section> </section>

View File

@@ -1,7 +1,9 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Script from "next/script";
import "@tabler/icons-webfont/dist/tabler-icons.min.css"; import "@tabler/icons-webfont/dist/tabler-icons.min.css";
import "./globals.css"; import "./globals.css";
import ConditionalNavbar from "./components/layout/ConditionalNavbar"; import ConditionalNavbar from "./components/layout/ConditionalNavbar";
import { ExtensionAttributeCleanup } from "./components/system/ExtensionAttributeCleanup"; // CHANGE: added — fixes hydration mismatch caused by browser extensions
import { Footer } from "./components/layout"; import { Footer } from "./components/layout";
@@ -16,13 +18,38 @@ 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: added — fixes hydration mismatch caused by browser extensions */}
<ExtensionAttributeCleanup />
{/* CHANGE: navbar is conditional based on route, so we can hide it on login/register pages */}
<ConditionalNavbar /> <ConditionalNavbar />
{children} {children}
<Footer /> <Footer />
<Script
id="extension-attribute-pre-hydration-cleanup"
strategy="beforeInteractive"
>
{`(() => {
const attrs = ["bis_skin_checked", "bis_register", "__processed_by_bitdefender__"];
const selector = attrs.map((attr) => "[" + attr + "]").join(",");
function strip() {
document.querySelectorAll(selector).forEach((element) => {
attrs.forEach((attr) => element.removeAttribute(attr));
});
}
strip();
new MutationObserver(strip).observe(document, {
attributes: true,
childList: true,
subtree: true,
attributeFilter: attrs,
});
})();`}
</Script>
</body> </body>
</html> </html>
); );

120
middleware.ts Normal file
View File

@@ -0,0 +1,120 @@
// middleware.ts
// 1. Runs on the Edge runtime before any page component is rendered.
// 2. Handles three guards for the app:
// a. Authentication for all /dashboard/* routes — redirects to /login if
// no valid auth cookie is present.
// b. Role-based access control — blocks the "driver" role from admin routes.
// c. Keeps an authenticated user off the public home page — if a logged-in
// user manually navigates to "/" or "/home", they are sent back to
// /dashboard instead of letting the marketing/home page render.
// 3. IMPORTANT: this file must live at the project root (or as src/middleware.ts)
// for Next.js to auto-detect and execute it as middleware. It was previously
// located at src/middleware/middleware.ts, which Next.js does NOT recognize
// as the middleware entrypoint — so none of the guards below were ever
// actually running. That misplacement was the root cause of unauthenticated
// users being able to reach /dashboard/* pages directly, and of logged-in
// users being able to navigate back to "/" or "/home" unchecked.
import { NextRequest, NextResponse } from "next/server";
// 4. The cookie name must match what the server-side login action sets
// (see app/api/auth/set-cookie/route.ts).
const AUTH_COOKIE_NAME = "auth_token";
// 5. Route groups this middleware cares about.
const PROTECTED_PREFIX = "/dashboard";
const PUBLIC_HOME_PATHS = ["/", "/home"];
// 6. Lightweight JWT payload decoder.
// We only need the `role` claim — we do NOT verify the signature here
// because the backend already validates the token on every API request.
// Signature verification in middleware would require the secret to be
// bundled into the Edge runtime, which is its own security concern.
// The authoritative check is always the backend; middleware is a UX guard.
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
// Base64url -> Base64 -> JSON
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
// atob is available in the Edge runtime
const json = atob(base64);
return JSON.parse(json) as Record<string, unknown>;
} catch {
return null;
}
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const authCookie = request.cookies.get(AUTH_COOKIE_NAME);
const isAuthenticated = !!authCookie?.value && !!decodeJwtPayload(authCookie.value);
// 7. Guard 1: public home ("/" and "/home"). An authenticated user must not
// be able to land back on the marketing/home page by manually editing the
// URL — bounce them to the dashboard instead. An unauthenticated visitor
// passes through normally.
if (PUBLIC_HOME_PATHS.includes(pathname)) {
if (isAuthenticated) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return NextResponse.next();
}
// 8. Guard 2: only the remaining checks apply to dashboard routes.
// (The `matcher` config below already limits execution, but this is an
// explicit check for clarity and defensive depth.)
if (!pathname.startsWith(PROTECTED_PREFIX)) {
return NextResponse.next();
}
// 9. Guard 3: authentication. No cookie at all -> redirect to /login,
// preserving the original URL so we can send the user back after
// a successful login.
if (!authCookie?.value) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
// 10. Decode the JWT payload (not verified — see decodeJwtPayload above).
const payload = decodeJwtPayload(authCookie.value);
if (!payload) {
// 11. Malformed or expired token — treat exactly like "not authenticated".
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
// 12. Guard 4: role-based access control. The role field matches what
// lib/auth.ts stores: a plain string like "driver", "admin", "user"
// (extracted from the backend JWT during login).
const role =
typeof payload.role === "string" ? payload.role.toLowerCase() : null;
const BLOCKED_ROLES = ["driver", "سائق"];
if (role && BLOCKED_ROLES.includes(role)) {
// 13. Driver accounts must never access the admin dashboard.
return NextResponse.redirect(new URL("/forbidden", request.url));
}
// 14. All checks passed — allow the request through.
return NextResponse.next();
}
export const config = {
// 15. Apply this middleware to the public home paths (so authenticated
// users get bounced off them) and to the dashboard root plus every
// sub-route (so unauthenticated visitors and blocked roles get
// redirected away from admin pages).
matcher: [
"/",
"/home",
"/dashboard",
"/dashboard/:path*",
],
};

View File

@@ -1,13 +1,31 @@
"use client"; "use client";
import { useForm } from "react-hook-form"; import { useEffect, useMemo, useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Button, Input, Modal } from "../UI"; import { Alert, Button, Input, Modal, Select } from "../UI";
import { createBranchSchema, updateBranchSchema } from "@/src/validations/branch.validator"; import { createBranchSchema, updateBranchSchema } from "@/src/validations/branch.validator";
import {
getRegions,
getCitiesByRegion,
getDistrictsByCity,
resolveLocation,
findLocationByNames,
} from "@/src/lib/locationHierarchy";
import type { Branch, BranchFormData } from "@/src/types/branch"; import type { Branch, BranchFormData } from "@/src/types/branch";
const FORM_ID = "branch-form"; const FORM_ID = "branch-form";
// Step 1: Extend the plain form-values shape with the three hierarchy ids.
// BranchFormData itself stays untouched (see src/types/branch.ts) — city/
// state/district there are still plain strings, since that's the API
// contract. This local type is what react-hook-form actually manages.
interface BranchFormValues extends BranchFormData {
regionId: string;
cityId: string;
districtId: string;
}
// ── Props ───────────────────────────────────────────────────────────────────── // ── Props ─────────────────────────────────────────────────────────────────────
interface BranchFormModalProps { interface BranchFormModalProps {
editBranch: Branch | null; editBranch: Branch | null;
@@ -19,22 +37,36 @@ interface BranchFormModalProps {
export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) { export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormModalProps) {
const isNew = editBranch === null; const isNew = editBranch === null;
// Step 2: On edit, try to resolve the branch's stored city/state/district
// NAMES back into ids against the current dataset. If nothing matches
// (the value is a legacy/unknown location no longer in the hierarchy),
// resolved stays null and the form falls back to empty selects — the
// person must actively re-pick a valid Region -> City -> District chain
// before they can save, rather than silently keeping a stale value.
const resolvedEdit = useMemo(
() => (editBranch ? findLocationByNames(editBranch.state, editBranch.city, editBranch.district) : null),
[editBranch],
);
const isLegacyLocation = !isNew && !resolvedEdit && !!(editBranch?.city || editBranch?.district);
const { const {
register, register,
handleSubmit, handleSubmit,
setError, setError,
setValue,
watch,
control,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
} = useForm<BranchFormData>({ } = useForm<BranchFormValues>({
// Cast the schema itself and pin the generic explicitly on yupResolver — resolver: yupResolver<BranchFormValues>((isNew ? createBranchSchema : updateBranchSchema) as any),
// create/update schemas differ structurally in which fields are required
// (e.g. city/street are optional on update), so yup can't unify them
// into the single flat BranchFormData shape on its own.
resolver: yupResolver<BranchFormData>((isNew ? createBranchSchema : updateBranchSchema) as any),
defaultValues: { defaultValues: {
name: editBranch?.name ?? "", name: editBranch?.name ?? "",
email: editBranch?.email ?? "", email: editBranch?.email ?? "",
phone: editBranch?.phone ?? "", phone: editBranch?.phone ?? "",
country: editBranch?.country ?? "SA", country: editBranch?.country ?? "SA",
regionId: resolvedEdit?.regionId ?? "",
cityId: resolvedEdit?.cityId ?? "",
districtId: resolvedEdit?.districtId ?? "",
city: editBranch?.city ?? "", city: editBranch?.city ?? "",
state: editBranch?.state ?? "", state: editBranch?.state ?? "",
district: editBranch?.district ?? "", district: editBranch?.district ?? "",
@@ -42,13 +74,71 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
buildingNo: editBranch?.buildingNo ?? "", buildingNo: editBranch?.buildingNo ?? "",
unitNo: editBranch?.unitNo ?? "", unitNo: editBranch?.unitNo ?? "",
zipCode: editBranch?.zipCode ?? "", zipCode: editBranch?.zipCode ?? "",
latitude: editBranch?.latitude != null ? String(editBranch.latitude) : "", latitude: resolvedEdit?.latitude != null ? String(resolvedEdit.latitude) : editBranch?.latitude != null ? String(editBranch.latitude) : "",
longitude: editBranch?.longitude != null ? String(editBranch.longitude) : "", longitude: resolvedEdit?.longitude != null ? String(resolvedEdit.longitude) : editBranch?.longitude != null ? String(editBranch.longitude) : "",
}, },
}); });
const submitHandler = async (data: BranchFormData) => { // Step 3: Watch the two parent selects so the child dropdowns can filter
const ok = await onSubmit(data, isNew); // themselves reactively (cascading Region -> City -> District).
const watchedRegionId = watch("regionId");
const watchedCityId = watch("cityId");
const watchedDistrictId = watch("districtId");
const regions = useMemo(() => getRegions(), []);
const cities = useMemo(() => getCitiesByRegion(watchedRegionId), [watchedRegionId]);
const districts = useMemo(
() => getDistrictsByCity(watchedRegionId, watchedCityId),
[watchedRegionId, watchedCityId],
);
// Step 4: Whenever the region changes, clear any city/district selection
// that no longer belongs to it — prevents the exact "Mohandessin under
// Cairo" class of bug from ever reaching submit.
useEffect(() => {
if (!watchedCityId) return;
const stillValid = cities.some((c) => c.id === watchedCityId);
if (!stillValid) {
setValue("cityId", "");
setValue("districtId", "");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [watchedRegionId]);
useEffect(() => {
if (!watchedDistrictId) return;
const stillValid = districts.some((d) => d.id === watchedDistrictId);
if (!stillValid) setValue("districtId", "");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [watchedCityId]);
// Step 5: Whenever the fully-resolved district changes, auto-populate
// latitude/longitude from the dataset — no manual coordinate entry.
useEffect(() => {
const resolved = resolveLocation(watchedRegionId, watchedCityId, watchedDistrictId);
if (resolved) {
setValue("latitude", String(resolved.latitude));
setValue("longitude", String(resolved.longitude));
}
}, [watchedRegionId, watchedCityId, watchedDistrictId, setValue]);
const submitHandler = async (data: BranchFormValues) => {
// Step 6: Resolve the chosen ids into display names right before
// building the payload, so the API keeps receiving plain strings for
// city/state/district exactly as it does today — this UI change is
// fully transparent to the backend contract.
const resolved = resolveLocation(data.regionId, data.cityId, data.districtId);
const payload: BranchFormData = {
...data,
state: resolved?.regionName ?? data.state,
city: resolved?.cityName ?? data.city,
district: resolved?.districtName ?? data.district,
latitude: resolved ? String(resolved.latitude) : data.latitude,
longitude: resolved ? String(resolved.longitude) : data.longitude,
};
const ok = await onSubmit(payload, isNew);
if (ok) { if (ok) {
onClose(); onClose();
} else { } else {
@@ -84,6 +174,17 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} /> <Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
)} )}
{/* Step 7: Warn once if the stored location no longer matches any
valid Region -> City -> District chain, and require a fresh
selection instead of silently keeping the legacy value. */}
{isLegacyLocation && (
<Alert
type="warning"
message="الموقع المحفوظ لهذا الفرع لم يعد مطابقًا لأي منطقة/مدينة/حي معروف. الرجاء اختيار الموقع الصحيح من القوائم أدناه."
onClose={() => {}}
/>
)}
{/* name */} {/* name */}
<Input <Input
label="اسم الفرع *" label="اسم الفرع *"
@@ -117,15 +218,73 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
/> />
</div> </div>
{/* city + street */} {/* Step 8: Cascading Region -> City -> District selects. Each
child <Select> is disabled until its parent has a value, and
its options come straight from the resolved parent id — a
district literally cannot be picked unless it belongs to the
currently-selected city. */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Input <Controller
label="المدينة *" name="regionId"
{...register("city")} control={control}
error={errors.city?.message} render={({ field }) => (
placeholder="الرياض" <Select
label="المنطقة *"
value={field.value}
onChange={field.onChange}
error={(errors as any).regionId?.message}
dir="rtl" dir="rtl"
>
<option value="">اختر المنطقة</option>
{regions.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</Select>
)}
/> />
<Controller
name="cityId"
control={control}
render={({ field }) => (
<Select
label="المدينة *"
value={field.value}
onChange={field.onChange}
error={(errors as any).cityId?.message}
disabled={!watchedRegionId}
dir="rtl"
>
<option value="">{watchedRegionId ? "اختر المدينة" : "اختر المنطقة أولاً"}</option>
{cities.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</Select>
)}
/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Controller
name="districtId"
control={control}
render={({ field }) => (
<Select
label="الحي *"
value={field.value}
onChange={field.onChange}
error={(errors as any).districtId?.message}
disabled={!watchedCityId}
dir="rtl"
>
<option value="">{watchedCityId ? "اختر الحي" : "اختر المدينة أولاً"}</option>
{districts.map((d) => (
<option key={d.id} value={d.id}>{d.name}</option>
))}
</Select>
)}
/>
<Input <Input
label="الشارع *" label="الشارع *"
{...register("street")} {...register("street")}
@@ -135,24 +294,6 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
/> />
</div> </div>
{/* state + district */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Input
label="المنطقة"
{...register("state")}
error={errors.state?.message}
placeholder="منطقة الرياض"
dir="rtl"
/>
<Input
label="الحي"
{...register("district")}
error={errors.district?.message}
placeholder="حي العليا"
dir="rtl"
/>
</div>
{/* buildingNo + unitNo + zipCode */} {/* buildingNo + unitNo + zipCode */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<Input <Input
@@ -185,25 +326,33 @@ export function BranchFormModal({ editBranch, onClose, onSubmit }: BranchFormMod
error={errors.country?.message} error={errors.country?.message}
placeholder="SA" placeholder="SA"
dir="ltr" dir="ltr"
disabled
/> />
{/* latitude + longitude */} {/* Step 9: latitude/longitude are now READ-ONLY — they are derived
automatically from the selected district (Step 5 above), so
there is no manual entry and therefore no coordinate-vs-district
mismatch possible from this form. */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
<Input <Input
label="خط العرض (اختياري)" label="خط العرض (تلقائي)"
{...register("latitude")} {...register("latitude")}
error={errors.latitude?.message} error={errors.latitude?.message}
placeholder="24.7136" placeholder=""
dir="ltr" dir="ltr"
inputMode="decimal" inputMode="decimal"
readOnly
disabled
/> />
<Input <Input
label="خط الطول (اختياري)" label="خط الطول (تلقائي)"
{...register("longitude")} {...register("longitude")}
error={errors.longitude?.message} error={errors.longitude?.message}
placeholder="46.6753" placeholder=""
dir="ltr" dir="ltr"
inputMode="decimal" inputMode="decimal"
readOnly
disabled
/> />
</div> </div>
</form> </form>

View File

@@ -29,26 +29,18 @@ function KpiAnomalyBadge({ anomaly }: { anomaly: EntityKpi["anomaly"] }) {
); );
} }
function KpiCardCounts({ total, active, pending }: { total: number; active: number; pending: number }) { // Step 8: KpiCardCounts no longer takes/renders `active`/`pending` — it now
// only receives and displays `total`. The active/pending mini-boxes (and
// their inline styling) have been removed entirely; the total figure keeps
// its original styling/position exactly as before, so the card's overall
// layout is unchanged.
function KpiCardCounts({ total }: { total: number }) {
return ( return (
<div style={{ marginTop: "0.875rem" }}> <div style={{ marginTop: "0.875rem" }}>
<p style={{ fontSize: "2rem", fontWeight: 700, color: "var(--color-text-dark-primary)", fontFamily: "var(--font-mono)", margin: 0, textAlign: "start" }}> <p style={{ fontSize: "2rem", fontWeight: 700, color: "var(--color-text-dark-primary)", fontFamily: "var(--font-mono)", margin: 0, textAlign: "start" }}>
{total.toLocaleString("ar-SA")} {total.toLocaleString("ar-SA")}
</p> </p>
<div style={{ marginTop: "0.625rem", display: "flex", gap: "0.75rem" }}>
<div style={{ flex: 1, borderRadius: "var(--radius-md)", background: "rgba(52,211,153,0.08)", border: "1px solid rgba(52,211,153,0.2)", padding: "0.375rem 0.625rem" }}>
<p style={{ fontSize: 10, color: "#6EE7B7", margin: 0 }}>نشط</p>
<p style={{ fontSize: 13, fontWeight: 700, color: "#A7F3D0", margin: 0, fontFamily: "var(--font-mono)" }}>
{active.toLocaleString("ar-SA")}
</p>
</div>
<div style={{ flex: 1, borderRadius: "var(--radius-md)", background: "rgba(251,191,36,0.08)", border: "1px solid rgba(251,191,36,0.2)", padding: "0.375rem 0.625rem" }}>
<p style={{ fontSize: 10, color: "#FCD34D", margin: 0 }}>معلَّق</p>
<p style={{ fontSize: 13, fontWeight: 700, color: "#FDE68A", margin: 0, fontFamily: "var(--font-mono)" }}>
{pending.toLocaleString("ar-SA")}
</p>
</div>
</div>
</div> </div>
); );
} }
@@ -71,6 +63,7 @@ function KpiCard({ entity }: { entity: EntityKpi }) {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
style={{ style={{
width: 40, height: 40, borderRadius: "var(--radius-lg)", width: 40, height: 40, borderRadius: "var(--radius-lg)",
@@ -87,7 +80,9 @@ function KpiCard({ entity }: { entity: EntityKpi }) {
<i className="ti ti-chevron-left" style={{ fontSize: 14, color: "var(--color-text-dark-muted)" }} aria-hidden="true" /> <i className="ti ti-chevron-left" style={{ fontSize: 14, color: "var(--color-text-dark-muted)" }} aria-hidden="true" />
</div> </div>
<KpiCardCounts total={entity.total} active={entity.active} pending={entity.pending} /> {/* Step 9: only `total` is passed now — `active`/`pending` props removed
from this call site along with the corresponding fields on EntityKpi. */}
<KpiCardCounts total={entity.total} />
<KpiAnomalyBadge anomaly={entity.anomaly} /> <KpiAnomalyBadge anomaly={entity.anomaly} />
</Link> </Link>
); );
@@ -101,6 +96,7 @@ interface KpiSectionProps {
export function KpiSection({ entities, loading }: KpiSectionProps) { export function KpiSection({ entities, loading }: KpiSectionProps) {
// Before the overview resolves, render the static config as zeroed cards // Before the overview resolves, render the static config as zeroed cards
// so the grid never collapses to nothing. // so the grid never collapses to nothing.
const list = entities.length > 0 ? entities : ENTITY_KPI_CONFIG.map((cfg) => ({ ...cfg, ...EMPTY_ENTITY_KPI })); const list = entities.length > 0 ? entities : ENTITY_KPI_CONFIG.map((cfg) => ({ ...cfg, ...EMPTY_ENTITY_KPI }));
return ( return (

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,21 +485,33 @@ 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>

View File

@@ -373,7 +373,7 @@ export function OrderFormModal({
</Select> </Select>
<Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}> <Link href="/dashboard/clients" style={createLinkStyle} tabIndex={-1}>
<i className="ti ti-plus" style={{ fontSize: 11 }} aria-hidden="true" /> <i className="ti ti-plus" style={{ fontSize: 11 }} aria-hidden="true" />
إنشاء عميل جديد إضافة عميل جديد
</Link> </Link>
</div> </div>

View File

@@ -54,14 +54,14 @@ export function OrderTable({
</div> </div>
), ),
}, },
{ key: "client", header: "العميل", width: "1.2fr", render: (o) => o.client?.name ?? "—" }, { key: "client", header: "العميل", width: "1.2fr", render: (o) => o.clientId ?? "—" },
{ {
key: "amount", key: "amount",
header: "الكمية", header: "الكمية",
width: "1.1fr", width: "1.1fr",
render: (o) => ( render: (o) => (
<span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}> <span style={{ fontWeight: 600, color: "var(--color-text-primary)", fontFamily: "var(--font-mono)" }}>
{fmtAmount(o.totalPrice)} {o.quantity ?? "—"}
</span> </span>
), ),
}, },

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 ────────────────────────────────────
// 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(); 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

@@ -1,56 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import type { Notification } from "@/src/hooks/useUser";
interface ToastProps {
notification: Notification | null;
}
export function Toast({ notification }: ToastProps) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (notification) {
setVisible(true);
} else {
// dismiss after 250ms to allow exit animation
const t = setTimeout(() => setVisible(false), 300);
return () => clearTimeout(t);
}
}, [notification]);
if (!visible && !notification) return null;
const isSuccess = notification?.type === "success";
return (
<div
role="status" aria-live="polite" aria-atomic="true"
style={{
position: "fixed", bottom: 24, left: "50%",
transform: `translateX(-50%) translateY(${notification ? "0" : "16px"})`,
zIndex: 9999,
transition: "transform 250ms ease, opacity 250ms ease",
opacity: notification ? 1 : 0,
pointerEvents: "none",
}}
>
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "0.75rem 1.25rem",
borderRadius: "var(--radius-full)",
background: isSuccess ? "#065F46" : "#7F1D1D",
color: "#FFFFFF",
fontSize: 13, fontWeight: 600,
boxShadow: "0 8px 32px rgba(0,0,0,0.25)",
maxWidth: "90vw", whiteSpace: "nowrap",
fontFamily: "var(--font-sans)",
}}>
{/* icon */}
<span style={{ fontSize: 16 }}>{isSuccess ? "✓" : "⚠"}</span>
<span>{notification?.message}</span>
</div>
</div>
);
}

View File

@@ -1,11 +1,11 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import type { Resolver } from "react-hook-form"; import type { Resolver } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Button, Input, Select } from "../UI"; import { Alert, Button, Input, Select } from "../UI";
import { createUserSchema, updateUserSchema } from "@/src/validations/user.validator"; import { createUserSchema, updateUserSchema } from "@/src/validations/user.validator";
import { useEditFormSync } from "@/src/hooks/useEditFormSync"; // CHANGE: added hook — fixes roleId/branchId not pre-selecting on edit
import type { Branch } from "@/src/types/branch"; import type { Branch } from "@/src/types/branch";
import type { Role } from "@/src/types/role"; import type { Role } from "@/src/types/role";
import type { User, UserFormData } from "@/src/types/user"; import type { User, UserFormData } from "@/src/types/user";
@@ -30,11 +30,6 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
const resolver: Resolver<UserFormData> = async (values, context, options) => { const resolver: Resolver<UserFormData> = async (values, context, options) => {
const schema = isNew ? createUserSchema : updateUserSchema; const schema = isNew ? createUserSchema : updateUserSchema;
const data = !isNew && !values.password ? { ...values, password: undefined } : values; const data = !isNew && !values.password ? { ...values, password: undefined } : values;
// Cast both the schema argument and yupResolver's own return value — same
// fix already applied in TripFormModal/DriverFormModal/CarFormModal:
// passing an explicit <UserFormData> generic here forces a structural
// comparison between yup's inferred optional-field shape and
// UserFormData that fails the same way it did for those forms.
return (yupResolver(schema as any) as any)(data as UserFormData, context, options); return (yupResolver(schema as any) as any)(data as UserFormData, context, options);
}; };
@@ -56,25 +51,26 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
}, },
}); });
// roles/branches are passed in as props rather than fetched inside this // CHANGE: replaced the two manual useEffect blocks with useEditFormSync —
// modal (unlike Trip/Driver/Car), but the same timing issue applies if the // same behavior (re-apply saved id once its option list contains it),
// parent still has them loading when the modal first mounts: defaultValues // reused across Driver/Car/Trip forms that have the same bug pattern.
// are applied before the matching <option> exists, so the <select> useEditFormSync(setValue, "roleId", editUser?.role?.id, roles);
// silently falls back to "". Reapply the saved id once each list actually useEditFormSync(setValue, "branchId", editUser?.branch?.id, branches);
// contains it.
// CHANGE: surface an inline error if the saved role/branch no longer
// exists in the fetched list, instead of silently falling back to the
// placeholder with no explanation.
useEffect(() => { useEffect(() => {
const savedRoleId = editUser?.role?.id; if (editUser?.role?.id && roles.length && !roles.some(r => r.id === editUser.role!.id)) {
if (savedRoleId && roles.some(r => r.id === savedRoleId)) { setError("roleId", { message: "الدور المحفوظ لم يعد متاحًا، الرجاء اختيار دور آخر" });
setValue("roleId", savedRoleId);
} }
}, [roles, editUser, setValue]); }, [roles, editUser, setError]);
useEffect(() => { useEffect(() => {
const savedBranchId = editUser?.branch?.id; if (editUser?.branch?.id && branches.length && !branches.some(b => b.id === editUser.branch!.id)) {
if (savedBranchId && branches.some(b => b.id === savedBranchId)) { setError("branchId", { message: "الفرع المحفوظ لم يعد متاحًا، الرجاء اختيار فرع آخر" });
setValue("branchId", savedBranchId);
} }
}, [branches, editUser, setValue]); }, [branches, editUser, setError]);
const submitHandler = async (data: UserFormData) => { const submitHandler = async (data: UserFormData) => {
const payload: Partial<UserFormData> = { ...data }; const payload: Partial<UserFormData> = { ...data };
@@ -136,7 +132,17 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
</div> </div>
{/* body */} {/* body */}
<form onSubmit={handleSubmit(submitHandler)} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}> {/* CHANGE: added suppressHydrationWarning — form elements are the
most common target for browser-extension-injected attributes
(e.g. bis_skin_checked from Bitdefender), which caused the
hydration mismatch error on Save. See ExtensionAttributeCleanup
for the global fix; this is a scoped safety net. */}
<form
onSubmit={handleSubmit(submitHandler)}
noValidate
suppressHydrationWarning
style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}
>
{errors.name?.type === "manual" && ( {errors.name?.type === "manual" && (
<Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} /> <Alert type="error" message={errors.name.message ?? ""} onClose={() => {}} />
)} )}

View File

@@ -6,7 +6,10 @@ import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Button, Input } from "@/src/Components/UI"; import { Alert, Button, Input } from "@/src/Components/UI";
import { useAuth } from "@/src/hooks/useAuth"; import { useAuth } from "@/src/hooks/useAuth";
import Logo from "@/src/utils/logo"; import Logo from "@/src/utils/logo";
import { loginSchema, type LoginFormData } from "@/src/validations/auth.validator"; import {
loginSchema,
type LoginFormData,
} from "@/src/validations/auth.validator";
export function LoginForm() { export function LoginForm() {
const { login, loading, error, clearError } = useAuth(); const { login, loading, error, clearError } = useAuth();
@@ -25,47 +28,212 @@ export function LoginForm() {
}; };
return ( return (
<main style={{ minHeight: "100vh", background: "var(--color-surface-muted)", color: "var(--color-text-primary)" }}> <main
<section style={{ minHeight: "100vh", width: "100%", display: "grid", gridTemplateColumns: "1fr 1fr" }}> style={{
<aside style={{ position: "relative", overflow: "hidden", background: "linear-gradient(135deg, #0f172a 0%, #2563EB 50%, #3b82f6 100%)", color: "#FFF", padding: "2.5rem 3rem", display: "flex", flexDirection: "column", justifyContent: "space-between" }}> minHeight: "100vh",
<div aria-hidden="true" style={{ position: "absolute", inset: 0, opacity: 0.28, backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)", backgroundSize: "40px 40px" }} /> background: "var(--color-surface-muted)",
<div style={{ position: "relative", zIndex: 1, display: "flex", flexDirection: "column", justifyContent: "space-between", height: "100%" }}> color: "var(--color-text-primary)",
}}
>
<section
style={{
minHeight: "100vh",
width: "100%",
display: "grid",
gridTemplateColumns: "1fr 1fr",
}}
>
<aside
style={{
position: "relative",
overflow: "hidden",
background:
"linear-gradient(135deg, #0f172a 0%, #2563EB 50%, #3b82f6 100%)",
color: "#FFF",
padding: "2.5rem 3rem",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
<div
aria-hidden="true"
style={{
position: "absolute",
inset: 0,
opacity: 0.28,
backgroundImage:
"radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)",
backgroundSize: "40px 40px",
}}
/>
<div
style={{
position: "relative",
zIndex: 1,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
height: "100%",
}}
>
<div> <div>
<Logo white={true} /> <Logo white={true} href="/" />
<h1 style={{ marginTop: "2.5rem", maxWidth: "28rem", fontSize: "2.25rem", fontWeight: 700, lineHeight: 1.2, letterSpacing: "-0.02em" }}> <h1
style={{
marginTop: "2.5rem",
maxWidth: "28rem",
fontSize: "2.25rem",
fontWeight: 700,
lineHeight: 1.2,
letterSpacing: "-0.02em",
}}
>
اللوجستيات ببساطة، والتسليم بثقة. اللوجستيات ببساطة، والتسليم بثقة.
</h1> </h1>
<p style={{ marginTop: "1rem", maxWidth: "28rem", fontSize: "0.95rem", lineHeight: 1.8, color: "rgba(255,255,255,0.75)" }}> <p
راقب عمليات التسليم، وقم بتنظيم السائقين، وحافظ على رؤية كل طلب من خلال تسجيل دخول آمن واحد. style={{
marginTop: "1rem",
maxWidth: "28rem",
fontSize: "0.95rem",
lineHeight: 1.8,
color: "rgba(255,255,255,0.75)",
}}
>
راقب عمليات التسليم، وقم بتنظيم السائقين، وحافظ على رؤية كل طلب
من خلال تسجيل دخول آمن واحد.
</p> </p>
<div style={{ marginTop: "2rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}> <div
style={{
marginTop: "2rem",
display: "flex",
flexDirection: "column",
gap: "0.75rem",
}}
>
{[ {[
["التنسيق اللحظي", "تتبع كل طلب من الاستلام إلى التسليم في مكان واحد."], [
["وصول آمن", "الجلسات المعتمدة على الصلاحيات تبقي مسارات العميل والإدارة منفصلة."], "التنسيق اللحظي",
["عمليات سريعة", "استخدم لوحة الإدارة لمراجعة الحالة والتنبيهات وحركة الرحلات."], "تتبع كل طلب من الاستلام إلى التسليم في مكان واحد.",
],
[
"وصول آمن",
"الجلسات المعتمدة على الصلاحيات تبقي مسارات العميل والإدارة منفصلة.",
],
[
"عمليات سريعة",
"استخدم لوحة الإدارة لمراجعة الحالة والتنبيهات وحركة الرحلات.",
],
].map(([title, desc]) => ( ].map(([title, desc]) => (
<article key={title} style={{ display: "flex", alignItems: "flex-start", gap: "0.75rem", borderRadius: "0.75rem", border: "1px solid rgba(255,255,255,0.15)", background: "rgba(255,255,255,0.1)", padding: "0.85rem" }}> <article
<div aria-hidden="true" style={{ marginTop: "0.125rem", flexShrink: 0, width: "2.25rem", height: "2.25rem", borderRadius: "0.6rem", background: "rgba(255,255,255,0.15)", display: "flex", alignItems: "center", justifyContent: "center", fontSize: "0.95rem" }}> key={title}
style={{
display: "flex",
alignItems: "flex-start",
gap: "0.75rem",
borderRadius: "0.75rem",
border: "1px solid rgba(255,255,255,0.15)",
background: "rgba(255,255,255,0.1)",
padding: "0.85rem",
}}
>
<div
aria-hidden="true"
style={{
marginTop: "0.125rem",
flexShrink: 0,
width: "2.25rem",
height: "2.25rem",
borderRadius: "0.6rem",
background: "rgba(255,255,255,0.15)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.95rem",
}}
>
</div> </div>
<div> <div>
<h2 style={{ fontSize: "0.9rem", fontWeight: 600, margin: 0 }}>{title}</h2> <h2
<p style={{ marginTop: "0.25rem", fontSize: "0.8rem", color: "rgba(255,255,255,0.8)" }}>{desc}</p> style={{
fontSize: "0.9rem",
fontWeight: 600,
margin: 0,
}}
>
{title}
</h2>
<p
style={{
marginTop: "0.25rem",
fontSize: "0.8rem",
color: "rgba(255,255,255,0.8)",
}}
>
{desc}
</p>
</div> </div>
</article> </article>
))} ))}
</div> </div>
</div> </div>
<p style={{ fontSize: "0.75rem", color: "rgba(255,255,255,0.5)" }}>مصمم لفرق العمليات التي تحتاج إلى الوضوح والسرعة والثقة.</p> <p style={{ fontSize: "0.75rem", color: "rgba(255,255,255,0.5)" }}>
مصمم لفرق العمليات التي تحتاج إلى الوضوح والسرعة والثقة.
</p>
</div> </div>
</aside> </aside>
<section style={{ display: "flex", alignItems: "center", justifyContent: "center", background: "var(--color-surface)", padding: "2.5rem 1.5rem" }}> <section
<form onSubmit={handleSubmit(onSubmit)} style={{ width: "100%", maxWidth: "26rem", borderRadius: "0.9rem", border: "1px solid var(--color-border)", background: "#FFF", padding: "2rem", boxShadow: "var(--shadow-card)" }} noValidate> style={{
<p style={{ fontSize: "1.75rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>مرحبًا بعودتك</p> display: "flex",
<p style={{ marginTop: "0.35rem", fontSize: "0.9rem", color: "var(--color-text-muted)" }}>سجل الدخول لإدارة عمليات التسليم</p> alignItems: "center",
justifyContent: "center",
background: "var(--color-surface)",
padding: "2.5rem 1.5rem",
}}
>
<form
onSubmit={handleSubmit(onSubmit)}
style={{
width: "100%",
maxWidth: "26rem",
borderRadius: "0.9rem",
border: "1px solid var(--color-border)",
background: "#FFF",
padding: "2rem",
boxShadow: "var(--shadow-card)",
}}
noValidate
>
<p
style={{
fontSize: "1.75rem",
fontWeight: 700,
color: "var(--color-text-primary)",
margin: 0,
}}
>
مرحبًا بعودتك
</p>
<p
style={{
marginTop: "0.35rem",
fontSize: "0.9rem",
color: "var(--color-text-muted)",
}}
>
سجل الدخول لإدارة عمليات التسليم
</p>
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}> <div
style={{
marginTop: "1.5rem",
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
<Input <Input
label="البريد الإلكتروني أو رقم الهاتف أو اسم المستخدم" label="البريد الإلكتروني أو رقم الهاتف أو اسم المستخدم"
autoComplete="username" autoComplete="username"
@@ -84,8 +252,23 @@ export function LoginForm() {
/> />
</div> </div>
<div style={{ marginTop: "0.75rem", display: "flex", justifyContent: "flex-end" }}> <div
<Link href="/forgot-password" style={{ fontSize: "0.8rem", color: "var(--color-brand-600)", textDecoration: "none" }}> style={{
marginTop: "0.75rem",
display: "flex",
justifyContent: "flex-end",
}}
>
<Link
href="/forgot-password"
style={{
fontSize: "0.8rem",
color: "var(--color-brand-600)",
textDecoration: "none",
pointerEvents: "none",
opacity: 0.5,
}}
>
هل نسيت كلمة المرور؟ هل نسيت كلمة المرور؟
</Link> </Link>
</div> </div>
@@ -96,13 +279,35 @@ export function LoginForm() {
</div> </div>
)} )}
<Button type="submit" loading={loading} fullWidth className="mt-6 h-11"> <Button
type="submit"
loading={loading}
fullWidth
className="mt-6 h-11"
>
{loading ? "جاري تسجيل الدخول…" : "تسجيل الدخول"} {loading ? "جاري تسجيل الدخول…" : "تسجيل الدخول"}
</Button> </Button>
<p style={{ marginTop: "1rem", textAlign: "center", fontSize: "0.85rem", color: "var(--color-text-muted)" }}> <p
style={{
marginTop: "1rem",
textAlign: "center",
fontSize: "0.85rem",
color: "var(--color-text-muted)",
}}
>
ليس لديك حساب؟{" "} ليس لديك حساب؟{" "}
<Link href="/register" style={{ color: "var(--color-brand-600)", textDecoration: "none" }}> <Link
href="/register"
style={{
color: "var(--color-brand-600)",
textDecoration: "none",
pointerEvents: "none",
opacity: 0.5,
}}
aria-disabled="true"
tabIndex={-1}
>
إنشاء حساب إنشاء حساب
</Link> </Link>
</p> </p>

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,
@@ -135,37 +136,30 @@ 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
// <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 = editCar?.branch?.id;
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();
branchService branchService
.getOptions(token) .getOptions(token)
.then((list) => { .then((list) => {
queueMicrotask(() => { queueMicrotask(() => setBranches(list as unknown as Branch[]));
setBranches(list as unknown as Branch[]);
if (savedBranchId) setValue("branchId", savedBranchId);
});
}) })
.catch(() => { .catch(() => {
/* silently ignore */ /* silently ignore */
}); });
}, [branchesProp, editCar, setValue]); }, [branchesProp]);
useEffect(() => { useEffect(() => {
loadBranches(); loadBranches();
}, [loadBranches]); }, [loadBranches]);
// CHANGE: replaced the manual `if (savedBranchId) setValue("branchId", ...)`
// calls above with useEditFormSync.
useEditFormSync(setValue, "branchId", editCar?.branch?.id, branches);
const [apiError, setApiError] = useState(""); const [apiError, setApiError] = useState("");

54
src/data/location.ts Normal file
View File

@@ -0,0 +1,54 @@
// src/types/location.ts
//
// Step 1: Define the Parent/Child hierarchy for Saudi administrative
// locations: Region (المنطقة) -> City (المدينة) -> District (الحي).
// Step 2: Each District carries its own fixed latitude/longitude, so once
// a district is selected in the form, coordinates are derived automatically
// instead of being typed manually by the user.
// Step 3: Keep this file dependency-free (no imports) so it can be safely
// imported from both client components and validation schemas.
export interface DistrictEntry {
// Step 1: Stable id used as the <option value> and for lookups.
id: string;
// Step 2: Arabic display name (primary language of the UI).
name: string;
// Step 3: Optional English name, useful for search/debugging/logs.
nameEn?: string;
// Step 4: Approximate center point of the district — used to
// auto-populate latitude/longitude once the district is chosen.
latitude: number;
longitude: number;
// Step 5: Optional list of known streets for this district. Left empty
// means the UI falls back to a free-text street input.
streets?: string[];
}
export interface CityEntry {
id: string;
name: string;
nameEn?: string;
// Step 6: A city always belongs to exactly one region (enforced by the
// dataset's nesting, not by a back-reference field, to avoid drift).
districts: DistrictEntry[];
}
export interface RegionEntry {
id: string;
name: string;
nameEn?: string;
cities: CityEntry[];
}
// Step 7: Flat, resolved shape returned by lookup helpers — convenient for
// building the branch payload and for pre-filling the form on edit.
export interface ResolvedLocation {
regionId: string;
regionName: string;
cityId: string;
cityName: string;
districtId: string;
districtName: string;
latitude: number;
longitude: number;
}

View File

@@ -0,0 +1,18 @@
interface DistrictEntry {
name: string;
streets?: string[];
boundingBox?: { minLat: number; maxLat: number; minLng: number; maxLng: number };
}
interface CityEntry {
name: string;
boundingBox?: { minLat: number; maxLat: number; minLng: number; maxLng: number };
districts: DistrictEntry[];
}
interface StateEntry {
name: string;
cities: CityEntry[];
}
interface CountryLocationData {
countryCode: string;
states: StateEntry[];
}

View File

@@ -0,0 +1,323 @@
// src/data/saudiLocationHierarchy.ts
//
// Step 1: Static reference dataset for Saudi Arabia's 13 administrative
// regions (المناطق الإدارية), each with its main city/cities, and a
// representative sample of districts (أحياء) per city.
// Step 2: This is NOT an exhaustive list of every district in the Kingdom —
// it covers the major/most common districts per major city so the
// Region -> City -> District chain is enforceable end-to-end today.
// Step 3: To extend: add a new CityEntry under the right RegionEntry, or a
// new DistrictEntry under the right CityEntry. IDs must stay unique within
// their parent list. No other file needs to change — types, validation,
// and the form all read from this single source of truth.
// Step 4: Coordinates are approximate district-center points (WGS84,
// decimal degrees) — accurate enough to auto-fill latitude/longitude on
// district selection; not survey-grade.
import { RegionEntry } from "./location";
export const SAUDI_LOCATION_HIERARCHY: RegionEntry[] = [
// Step 5: Region 1 — الرياض (Riyadh)
{
id: "riyadh-region",
name: "منطقة الرياض",
nameEn: "Riyadh Region",
cities: [
{
id: "riyadh-city",
name: "الرياض",
nameEn: "Riyadh",
districts: [
{ id: "riyadh-olaya", name: "العليا", nameEn: "Al Olaya", latitude: 24.6944, longitude: 46.6858 },
{ id: "riyadh-malaz", name: "الملز", nameEn: "Al Malaz", latitude: 24.6553, longitude: 46.7373 },
{ id: "riyadh-nakheel", name: "النخيل", nameEn: "Al Nakheel", latitude: 24.7716, longitude: 46.6285 },
{ id: "riyadh-sulaimaniyah", name: "السليمانية", nameEn: "Al Sulaimaniyah", latitude: 24.6917, longitude: 46.7219 },
{ id: "riyadh-malqa", name: "الملقا", nameEn: "Al Malqa", latitude: 24.7962, longitude: 46.6289 },
],
},
{
id: "dawadmi-city",
name: "الدوادمي",
nameEn: "Al Dawadmi",
districts: [
{ id: "dawadmi-center", name: "وسط الدوادمي", nameEn: "Central Dawadmi", latitude: 24.5075, longitude: 44.3931 },
],
},
],
},
// Step 6: Region 2 — مكة المكرمة (Makkah)
{
id: "makkah-region",
name: "منطقة مكة المكرمة",
nameEn: "Makkah Region",
cities: [
{
id: "jeddah-city",
name: "جدة",
nameEn: "Jeddah",
districts: [
{ id: "jeddah-rawdah", name: "الروضة", nameEn: "Al Rawdah", latitude: 21.5646, longitude: 39.1728 },
{ id: "jeddah-shati", name: "الشاطئ", nameEn: "Al Shati", latitude: 21.6116, longitude: 39.1075 },
{ id: "jeddah-salamah", name: "السلامة", nameEn: "Al Salamah", latitude: 21.5729, longitude: 39.1541 },
{ id: "jeddah-hamra", name: "الحمراء", nameEn: "Al Hamra", latitude: 21.5498, longitude: 39.1611 },
],
},
{
id: "makkah-city",
name: "مكة المكرمة",
nameEn: "Makkah",
districts: [
{ id: "makkah-aziziyah", name: "العزيزية", nameEn: "Al Aziziyah", latitude: 21.3971, longitude: 39.8449 },
{ id: "makkah-shisha", name: "الششة", nameEn: "Al Shisha", latitude: 21.4267, longitude: 39.8134 },
],
},
{
id: "taif-city",
name: "الطائف",
nameEn: "Taif",
districts: [
{ id: "taif-shuhada", name: "الشهداء", nameEn: "Al Shuhada", latitude: 21.2854, longitude: 40.4183 },
{ id: "taif-salamah", name: "السلامة", nameEn: "Al Salamah", latitude: 21.2703, longitude: 40.3985 },
],
},
],
},
// Step 7: Region 3 — المدينة المنورة (Madinah)
{
id: "madinah-region",
name: "منطقة المدينة المنورة",
nameEn: "Madinah Region",
cities: [
{
id: "madinah-city",
name: "المدينة المنورة",
nameEn: "Madinah",
districts: [
{ id: "madinah-aziziyah", name: "العزيزية", nameEn: "Al Aziziyah", latitude: 24.4483, longitude: 39.5877 },
{ id: "madinah-quba", name: "قباء", nameEn: "Quba", latitude: 24.4392, longitude: 39.6142 },
{ id: "madinah-sultanah", name: "السلطانة", nameEn: "Al Sultanah", latitude: 24.5115, longitude: 39.5876 },
],
},
{
id: "yanbu-city",
name: "ينبع",
nameEn: "Yanbu",
districts: [
{ id: "yanbu-sinaiyah", name: "ينبع الصناعية", nameEn: "Yanbu Industrial", latitude: 24.0895, longitude: 38.0618 },
],
},
],
},
// Step 8: Region 4 — القصيم (Qassim)
{
id: "qassim-region",
name: "منطقة القصيم",
nameEn: "Qassim Region",
cities: [
{
id: "buraidah-city",
name: "بريدة",
nameEn: "Buraidah",
districts: [
{ id: "buraidah-faisaliyah", name: "الفيصلية", nameEn: "Al Faisaliyah", latitude: 26.3418, longitude: 43.9877 },
{ id: "buraidah-nahdah", name: "النهضة", nameEn: "Al Nahdah", latitude: 26.3592, longitude: 43.9721 },
],
},
{
id: "unaizah-city",
name: "عنيزة",
nameEn: "Unaizah",
districts: [
{ id: "unaizah-center", name: "وسط عنيزة", nameEn: "Central Unaizah", latitude: 26.0844, longitude: 43.9935 },
],
},
],
},
// Step 9: Region 5 — المنطقة الشرقية (Eastern Province)
{
id: "eastern-region",
name: "المنطقة الشرقية",
nameEn: "Eastern Province",
cities: [
{
id: "dammam-city",
name: "الدمام",
nameEn: "Dammam",
districts: [
{ id: "dammam-faisaliyah", name: "الفيصلية", nameEn: "Al Faisaliyah", latitude: 26.4260, longitude: 50.1050 },
{ id: "dammam-shati", name: "الشاطئ", nameEn: "Al Shati", latitude: 26.4457, longitude: 50.0928 },
],
},
{
id: "khobar-city",
name: "الخبر",
nameEn: "Al Khobar",
districts: [
{ id: "khobar-thuqbah", name: "الثقبة", nameEn: "Al Thuqbah", latitude: 26.2989, longitude: 50.2088 },
{ id: "khobar-aqrabiyah", name: "العقربية", nameEn: "Al Aqrabiyah", latitude: 26.2756, longitude: 50.1928 },
],
},
{
id: "ahsa-city",
name: "الأحساء",
nameEn: "Al Ahsa",
districts: [
{ id: "ahsa-mubarraz", name: "المبرز", nameEn: "Al Mubarraz", latitude: 25.4058, longitude: 49.5928 },
],
},
],
},
// Step 10: Region 6 — عسير (Asir)
{
id: "asir-region",
name: "منطقة عسير",
nameEn: "Asir Region",
cities: [
{
id: "abha-city",
name: "أبها",
nameEn: "Abha",
districts: [
{ id: "abha-sad", name: "السد", nameEn: "Al Sad", latitude: 18.2295, longitude: 42.5053 },
{ id: "abha-manhal", name: "المنهل", nameEn: "Al Manhal", latitude: 18.2465, longitude: 42.5117 },
],
},
{
id: "khamis-mushait-city",
name: "خميس مشيط",
nameEn: "Khamis Mushait",
districts: [
{ id: "khamis-center", name: "وسط خميس مشيط", nameEn: "Central Khamis Mushait", latitude: 18.3060, longitude: 42.7297 },
],
},
],
},
// Step 11: Region 7 — تبوك (Tabuk)
{
id: "tabuk-region",
name: "منطقة تبوك",
nameEn: "Tabuk Region",
cities: [
{
id: "tabuk-city",
name: "تبوك",
nameEn: "Tabuk",
districts: [
{ id: "tabuk-sulaymaniyah", name: "السليمانية", nameEn: "Al Sulaymaniyah", latitude: 28.3998, longitude: 36.5715 },
{ id: "tabuk-muruj", name: "المروج", nameEn: "Al Muruj", latitude: 28.3776, longitude: 36.5619 },
],
},
],
},
// Step 12: Region 8 — حائل (Hail)
{
id: "hail-region",
name: "منطقة حائل",
nameEn: "Hail Region",
cities: [
{
id: "hail-city",
name: "حائل",
nameEn: "Hail",
districts: [
{ id: "hail-nuqrah", name: "النقرة", nameEn: "Al Nuqrah", latitude: 27.5219, longitude: 41.6907 },
{ id: "hail-nafl", name: "النفل", nameEn: "Al Nafl", latitude: 27.5350, longitude: 41.7075 },
],
},
],
},
// Step 13: Region 9 — الحدود الشمالية (Northern Borders)
{
id: "northern-borders-region",
name: "منطقة الحدود الشمالية",
nameEn: "Northern Borders Region",
cities: [
{
id: "arar-city",
name: "عرعر",
nameEn: "Arar",
districts: [
{ id: "arar-center", name: "وسط عرعر", nameEn: "Central Arar", latitude: 30.9753, longitude: 41.0381 },
],
},
],
},
// Step 14: Region 10 — جازان (Jazan)
{
id: "jazan-region",
name: "منطقة جازان",
nameEn: "Jazan Region",
cities: [
{
id: "jazan-city",
name: "جازان",
nameEn: "Jazan",
districts: [
{ id: "jazan-corniche", name: "الكورنيش", nameEn: "Al Corniche", latitude: 16.8892, longitude: 42.5611 },
{ id: "jazan-rawdah", name: "الروضة", nameEn: "Al Rawdah", latitude: 16.9083, longitude: 42.5711 },
],
},
],
},
// Step 15: Region 11 — نجران (Najran)
{
id: "najran-region",
name: "منطقة نجران",
nameEn: "Najran Region",
cities: [
{
id: "najran-city",
name: "نجران",
nameEn: "Najran",
districts: [
{ id: "najran-nasim", name: "النسيم", nameEn: "Al Nasim", latitude: 17.5656, longitude: 44.2289 },
],
},
],
},
// Step 16: Region 12 — الباحة (Al Bahah)
{
id: "bahah-region",
name: "منطقة الباحة",
nameEn: "Al Bahah Region",
cities: [
{
id: "bahah-city",
name: "الباحة",
nameEn: "Al Bahah",
districts: [
{ id: "bahah-center", name: "وسط الباحة", nameEn: "Central Al Bahah", latitude: 20.0129, longitude: 41.4677 },
],
},
],
},
// Step 17: Region 13 — الجوف (Al Jouf)
{
id: "jouf-region",
name: "منطقة الجوف",
nameEn: "Al Jouf Region",
cities: [
{
id: "sakaka-city",
name: "سكاكا",
nameEn: "Sakaka",
districts: [
{ id: "sakaka-center", name: "وسط سكاكا", nameEn: "Central Sakaka", latitude: 29.9697, longitude: 40.2064 },
],
},
],
},
];

View File

@@ -79,8 +79,11 @@ export function useBranches() {
const updateBranch = useCallback(async (id: string, data: BranchFormData): Promise<boolean> => { const updateBranch = useCallback(async (id: string, data: BranchFormData): Promise<boolean> => {
try { try {
const token = getStoredToken(); const token = getStoredToken();
const res = await branchService.update(id, data, token); const branch = await branchService.update(id, data, token);
dispatch({ type: "UPDATE", branch: res?.data }); if (!branch?.id) {
throw new Error("لم يُرجع الخادم بيانات الفرع المحدث.");
}
dispatch({ type: "UPDATE", branch });
notify({ type: "success", message: "تم تحديث الفرع بنجاح." }); notify({ type: "success", message: "تم تحديث الفرع بنجاح." });
return true; return true;
} catch (err) { } catch (err) {

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

@@ -0,0 +1,22 @@
"use client";
import { useEffect } from "react";
import type { UseFormSetValue, FieldValues, Path } from "react-hook-form";
// Re-applies a saved id to a select field once its option list has
// finished loading — covers the case where the edit modal mounts
// before roles/branches finish fetching from the API.
export function useEditFormSync<T extends FieldValues>(
setValue: UseFormSetValue<T>,
fieldName: Path<T>,
savedId: string | undefined,
options: { id: string }[],
) {
useEffect(() => {
if (savedId && options.some(o => o.id === savedId)) {
// shouldDirty: false — this is a programmatic sync, not a user edit
setValue(fieldName, savedId as never, { shouldDirty: false });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [options, savedId, setValue]);
}

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) {

View File

@@ -0,0 +1,125 @@
// Step 1: Pure lookup helpers over SAUDI_LOCATION_HIERARCHY. No side
// effects, no fetch — the dataset is static and imported directly, so
// these functions are safe to call from both client components and the
// yup validation schema (see src/validations/branch.validator.ts).
import { SAUDI_LOCATION_HIERARCHY } from "@/src/data/saudiLocationHierarchy";
import { RegionEntry, CityEntry, DistrictEntry, ResolvedLocation } from "../data/location";
// Step 2: Return every region — used to populate the first dropdown.
export function getRegions(): RegionEntry[] {
return SAUDI_LOCATION_HIERARCHY;
}
// Step 3: Return the cities that belong to a given region id. Returns an
// empty array (not undefined) when the region id is unknown, so callers
// can render an empty <Select> safely without extra null checks.
export function getCitiesByRegion(regionId: string | undefined | null): CityEntry[] {
if (!regionId) return [];
const region = SAUDI_LOCATION_HIERARCHY.find((r) => r.id === regionId);
return region?.cities ?? [];
}
// Step 4: Return the districts that belong to a given city id, scoped to
// its parent region id (prevents accidentally resolving a city id that
// exists twice under two different regions — not currently possible with
// this dataset, but kept as a safety net for future data entry mistakes).
export function getDistrictsByCity(
regionId: string | undefined | null,
cityId: string | undefined | null,
): DistrictEntry[] {
if (!regionId || !cityId) return [];
const city = getCitiesByRegion(regionId).find((c) => c.id === cityId);
return city?.districts ?? [];
}
// Step 5: Resolve a full Region -> City -> District selection into a flat
// object with display names + auto-derived coordinates. Returns null if
// any part of the chain doesn't actually match (this is the core
// consistency check: a district id must genuinely belong to the given
// city id, which must genuinely belong to the given region id).
export function resolveLocation(
regionId: string | undefined | null,
cityId: string | undefined | null,
districtId: string | undefined | null,
): ResolvedLocation | null {
if (!regionId || !cityId || !districtId) return null;
const region = SAUDI_LOCATION_HIERARCHY.find((r) => r.id === regionId);
if (!region) return null;
const city = region.cities.find((c) => c.id === cityId);
if (!city) return null;
const district = city.districts.find((d) => d.id === districtId);
if (!district) return null;
return {
regionId: region.id,
regionName: region.name,
cityId: city.id,
cityName: city.name,
districtId: district.id,
districtName: district.name,
latitude: district.latitude,
longitude: district.longitude,
};
}
// Step 6: Convenience boolean wrapper around resolveLocation — used inside
// the yup `.test()` cross-field validator.
export function isValidLocationChain(
regionId: string | undefined | null,
cityId: string | undefined | null,
districtId: string | undefined | null,
): boolean {
return resolveLocation(regionId, cityId, districtId) !== null;
}
// Step 7: Look up a district's coordinates directly, used to auto-fill
// latitude/longitude the moment a district is selected in the form.
export function getDistrictCoordinates(
regionId: string | undefined | null,
cityId: string | undefined | null,
districtId: string | undefined | null,
): { latitude: number; longitude: number } | null {
const resolved = resolveLocation(regionId, cityId, districtId);
if (!resolved) return null;
return { latitude: resolved.latitude, longitude: resolved.longitude };
}
// Step 8: Handle the "edit mode with a legacy/unknown value" case — given
// a district NAME coming back from the API (not an id, since the backend
// stores display strings today per Branch/BranchDetail), try to find a
// matching region/city/district id triplet in the current dataset. If no
// match is found, the caller should treat the stored value as a legacy
// value and prompt the user to reselect (see BranchFormModal Step 4).
export function findLocationByNames(
regionName?: string | null,
cityName?: string | null,
districtName?: string | null,
): ResolvedLocation | null {
if (!regionName || !cityName || !districtName) return null;
for (const region of SAUDI_LOCATION_HIERARCHY) {
if (region.name !== regionName) continue;
for (const city of region.cities) {
if (city.name !== cityName) continue;
for (const district of city.districts) {
if (district.name !== districtName) continue;
return {
regionId: region.id,
regionName: region.name,
cityId: city.id,
cityName: city.name,
districtId: district.id,
districtName: district.name,
latitude: district.latitude,
longitude: district.longitude,
};
}
}
}
return null;
}

View File

@@ -1,118 +0,0 @@
// middleware.ts
// Runs on the Edge runtime before any page component is rendered.
// Handles two guards for all /dashboard/* routes (and their sub-routes):
// 1. Authentication — redirects to /login if no auth cookie is present.
// 2. Role-based access control — blocks the "driver" role from admin routes.
import { NextRequest, NextResponse } from "next/server";
// The cookie name must match what the server-side login action sets.
// Previously lib/auth.ts wrote this as a JS-accessible cookie — after the
// auth.ts refactor (Issue 3) this same name is now an HttpOnly cookie.
const AUTH_COOKIE_NAME = "auth_token";
// Routes that require authentication (and block the driver role).
// The matcher below handles the routing; this constant is for documentation.
const PROTECTED_PREFIX = "/dashboard";
/**
* Lightweight JWT payload decoder.
* We only need the `role` claim — we do NOT verify the signature here
* because the backend already validates the token on every API request.
* Signature verification in middleware would require the secret to be
* bundled into the Edge runtime, which is its own security concern.
* The authoritative check is always the backend; middleware is a UX guard.
*/
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
// Base64url → Base64 → JSON
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
// atob is available in the Edge runtime
const json = atob(base64);
return JSON.parse(json) as Record<string, unknown>;
} catch {
return null;
}
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// ── Guard: only apply to protected routes ─────────────────────────────────
// (The `matcher` config below already limits execution, but this is an
// explicit check for clarity and defensive depth.)
if (!pathname.startsWith(PROTECTED_PREFIX)) {
return NextResponse.next();
}
// ── Check 1: Authentication ────────────────────────────────────────────────
// Read the HttpOnly auth cookie set by the server after login.
const authCookie = request.cookies.get(AUTH_COOKIE_NAME);
if (!authCookie?.value) {
// No token → send to login, preserving the original URL so we can
// redirect back after successful login if needed.
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("next", pathname);
return NextResponse.redirect(loginUrl);
}
// ── Check 2: Role-based access control ────────────────────────────────────
// Decode the JWT payload (not verified — see note on decodeJwtPayload above).
const payload = decodeJwtPayload(authCookie.value);
if (!payload) {
// Malformed token — treat as unauthenticated.
const loginUrl = new URL("/login", request.url);
return NextResponse.redirect(loginUrl);
}
// The role field matches what lib/auth.ts stores: a plain string like
// "driver", "admin", "user" (extracted from the backend JWT during login).
const role =
typeof payload.role === "string"
? payload.role.toLowerCase()
: null;
const BLOCKED_ROLES = ["driver", "سائق"];
if (role && BLOCKED_ROLES.includes(role)) {
// Driver accounts must never access the admin dashboard.
return NextResponse.redirect(new URL("/forbidden", request.url));
}
// ── All checks passed — allow the request through ─────────────────────────
return NextResponse.next();
}
export const config = {
// Apply this middleware to the dashboard root and all sub-routes.
// Explicitly list the known sub-routes so the matcher is predictable;
// any new top-level pages added under /dashboard are automatically covered
// by the "/dashboard/:path*" pattern.
matcher: [
"/dashboard",
"/dashboard/:path*",
"/users",
"/users/:path*",
"/cars",
"/cars/:path*",
"/orders",
"/orders/:path*",
"/clients",
"/clients/:path*",
"/drivers",
"/drivers/:path*",
"/roles",
"/roles/:path*",
"/audit",
"/audit/:path*",
"/trips",
"/trips/:path*",
"/branches",
"/branches/:path*",
],
};

View File

@@ -0,0 +1,35 @@
import { get } from "./api";
import type { AuditLog } from "@/src/types/audit";
// Step 1: New service, following the exact same shape/pattern as the other
// list services in this file's siblings (userService.getAll, branchService.
// getAll, roleService.getAll) — GET with page/limit, unwrap { data: { data,
// meta|pagination } } into a clean { items, total } result. No audit service
// previously existed even though src/types/audit.ts and AuditTable already
// consume AuditLog, so this fills that gap using the established contract.
export interface AuditListResponse {
success: boolean;
message: string;
responseAt: string;
data: {
data: AuditLog[];
meta?: { total: number; page: number; limit: number; totalPages: number };
pagination?: { total: number; page: number; pages: number };
};
}
function buildAuditQuery(page: number, limit: number): string {
return `?page=${page}&limit=${limit}`;
}
export const auditService = {
// The real backend endpoint is /api/v1/audit and it responds with
// { data: { data: [...], meta: { total, page, limit, totalPages } } }.
getAll: async (page = 1, limit = 1): Promise<{ items: AuditLog[]; total: number }> => {
const res = await get<AuditListResponse>(`audit${buildAuditQuery(page, limit)}`);
return {
items: res.data?.data ?? [],
total: res.data?.meta?.total ?? res.data?.pagination?.total ?? 0,
};
},
};

View File

@@ -6,17 +6,35 @@ import type {
DashboardAlert, DashboardAlert,
} from "@/src/types/dashboard"; } from "@/src/types/dashboard";
import { ENTITY_KPI_CONFIG, EMPTY_ENTITY_KPI } from "@/src/types/dashboard"; import { ENTITY_KPI_CONFIG, EMPTY_ENTITY_KPI } from "@/src/types/dashboard";
// Step 3: Reuse the exact same services already used by UserTable,
// BranchTable, and RoleTable (via useUsers/useBranches/useRoles) instead of
// writing new fetch logic — this keeps the dashboard's data-fetching
// pattern identical to the rest of the app, per the task instructions.
import { userService } from "./user.service";
import { branchService } from "./branch.service";
import { roleService } from "./role.service";
import { auditService } from "./audit.service";
// Maps the stats we DO get from /dashboard/summary onto the entity cards. // Step 4: buildEntities now takes a second argument with the totals that
// Entities with no matching stat (users, branches, roles, audit) stay at 0 // don't come from /dashboard/summary (users, branches, roles, audit) and
// until the real /dashboard/overview endpoint exists. // merges them into the same totals map the clients/orders/trips/cars/
function buildEntities(stats: DashboardSummaryResponse["data"]["stats"]): EntityKpi[] { // drivers stats already flow through — no change to the entity list shape
// or ordering (ENTITY_KPI_CONFIG is untouched), so layout stays identical.
function buildEntities(
stats: DashboardSummaryResponse["data"]["stats"],
extraTotals: { users: number; branches: number; roles: number; audit: number },
): EntityKpi[] {
const totals: Partial<Record<EntityKpi["key"], number>> = { const totals: Partial<Record<EntityKpi["key"], number>> = {
clients: stats.clients, clients: stats.clients,
orders: stats.orders, orders: stats.orders,
trips: stats.trips, trips: stats.trips,
cars: stats.cars, cars: stats.cars,
drivers: stats.drivers, drivers: stats.drivers,
users: extraTotals.users,
branches: extraTotals.branches,
roles: extraTotals.roles,
audit: extraTotals.audit,
}; };
return ENTITY_KPI_CONFIG.map((cfg) => ({ return ENTITY_KPI_CONFIG.map((cfg) => ({
@@ -45,6 +63,7 @@ function buildAlerts(alerts: DashboardSummaryResponse["data"]["alerts"]): Dashbo
createdAt: now, createdAt: now,
})); }));
return [ return [
...mapGroup(alerts.expiringCars, "cars", "warning", "expiring-car"), ...mapGroup(alerts.expiringCars, "cars", "warning", "expiring-car"),
...mapGroup(alerts.expiringDrivers, "drivers", "warning", "expiring-driver"), ...mapGroup(alerts.expiringDrivers, "drivers", "warning", "expiring-driver"),
@@ -57,15 +76,37 @@ export const dashboardService = {
get<DashboardSummaryResponse>("dashboard/summary", token), get<DashboardSummaryResponse>("dashboard/summary", token),
// NOTE: /dashboard/overview isn't exposed by the backend yet, so this // NOTE: /dashboard/overview isn't exposed by the backend yet, so this
// composes the DashboardOverview shape client-side from /dashboard/summary, // composes the DashboardOverview shape client-side from /dashboard/summary
// same "additive, not destructive" pattern used elsewhere (see // plus the users/branches/roles/audit totals fetched below, same
// "additive, not destructive" pattern used elsewhere (see
// useArchivedCars/useArchivedRoles) while the real route isn't live. // useArchivedCars/useArchivedRoles) while the real route isn't live.
getOverview: async (token: string | null): Promise<DashboardOverview> => { getOverview: async (token: string | null): Promise<DashboardOverview> => {
const res = await get<DashboardSummaryResponse>("dashboard/summary", token ?? ""); const res = await get<DashboardSummaryResponse>("dashboard/summary", token ?? "");
const { stats, alerts, activeTrips, accountSecurity } = res.data; const { stats, alerts, activeTrips, accountSecurity } = res.data;
// Step 5: Fetch the four missing totals in parallel (mirrors the
// Promise.all-free-but-concurrent pattern already used in
// OrderFormModal, which loads clients + trips together for the same
// modal mount). page=1 + empty search is exactly what UserTable /
// BranchTable / RoleTable already send on first load — we just read
// back `.total` and discard the `.items` page since the KPI card only
// needs the count. Each call is defensively caught so one failing
// endpoint doesn't blank out the other three cards.
const [usersResult, branchesResult, rolesResult, auditResult] = await Promise.all([
userService.getAll(1, "", token).catch(() => ({ items: [], total: 0, pages: 1 })),
branchService.getAll(1, "", token).catch(() => ({ items: [], total: 0, pages: 1 })),
roleService.getAll(1, "", token).catch(() => ({ items: [], total: 0, pages: 1 })),
auditService.getAll(1, 1).catch(() => ({ items: [], total: 0 })),
]);
return { return {
entities: buildEntities(stats), entities: buildEntities(stats, {
users: usersResult.total,
branches: branchesResult.total,
roles: rolesResult.total,
audit: auditResult.total,
}),
alerts: buildAlerts(alerts), alerts: buildAlerts(alerts),
trends: [], // no trend endpoint yet trends: [], // no trend endpoint yet
recentActivity: [], // no audit-log endpoint composed yet recentActivity: [], // no audit-log endpoint composed yet

View File

@@ -90,6 +90,10 @@ export type DashboardEntityKey =
| "users" | "drivers" | "cars" | "trips" | "orders" | "users" | "drivers" | "cars" | "trips" | "orders"
| "clients" | "branches" | "roles" | "audit"; | "clients" | "branches" | "roles" | "audit";
// Step 6: Removed `active` and `pending` from EntityKpi — the stat cards
// (KpiSection.tsx) no longer break totals down into active/pending, they
// only display the total count, so the type no longer carries fields that
// are never rendered.
export interface EntityKpi { export interface EntityKpi {
key: DashboardEntityKey; key: DashboardEntityKey;
label: string; label: string;
@@ -99,18 +103,16 @@ export interface EntityKpi {
accent: string; accent: string;
href: string; href: string;
total: number; total: number;
active: number;
pending: number;
anomaly: { anomaly: {
severity: "warning" | "critical"; severity: "warning" | "critical";
message: string; message: string;
} | null; } | null;
} }
export const EMPTY_ENTITY_KPI: Pick<EntityKpi, "total" | "active" | "pending" | "anomaly"> = { // Step 7: EMPTY_ENTITY_KPI narrowed to match the trimmed EntityKpi shape —
// only `total` and `anomaly` are defaultable now.
export const EMPTY_ENTITY_KPI: Pick<EntityKpi, "total" | "anomaly"> = {
total: 0, total: 0,
active: 0,
pending: 0,
anomaly: null, anomaly: null,
}; };

View File

@@ -1,10 +1,13 @@
import { FC } from "react"; import { FC } from "react";
import Link from "next/link";
interface LogoProps { interface LogoProps {
white?: boolean; white?: boolean;
href?: string;
} }
const Logo: FC<LogoProps> = ({ white = false }) => ( const Logo: FC<LogoProps> = ({ white = false, href }) => {
const content = (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div style={{ <div style={{
width: 32, height: 32, width: 32, height: 32,
@@ -28,6 +31,17 @@ const Logo: FC<LogoProps> = ({ white = false }) => (
Slash.sa Slash.sa
</span> </span>
</div> </div>
); );
if (href) {
return (
<Link href={href} style={{ textDecoration: "none" }}>
{content}
</Link>
);
}
return content;
};
export default Logo; export default Logo;

View File

@@ -1,10 +1,29 @@
import * as yup from "yup"; import * as yup from "yup";
import { isValidLocationChain } from "@/src/lib/locationHierarchy";
// ── Phone regex — same as backend ───────────────────────────────────────────── // ── Phone regex — same as backend ─────────────────────────────────────────────
const SAUDI_PHONE_RE = /^(\+966|966|0)?5[0-9]{8}$/; const SAUDI_PHONE_RE = /^(\+966|966|0)?5[0-9]{8}$/;
// Step 1: Cross-field test shared by create/update schemas — verifies the
// selected regionId/cityId/districtId genuinely form a valid parent/child
// chain in SAUDI_LOCATION_HIERARCHY (e.g. rejects "Mohandessin"-style
// mismatches such as a district that doesn't belong to the chosen city).
// Step 2: Only runs when all three ids are present — required-ness of each
// individual field is handled by its own `.required()` rule below, so this
// test focuses purely on chain CONSISTENCY, not presence.
function locationChainTest(this: yup.TestContext): boolean {
const { regionId, cityId, districtId } = this.parent as {
regionId?: string;
cityId?: string;
districtId?: string;
};
if (!regionId || !cityId || !districtId) return true; // presence handled elsewhere
return isValidLocationChain(regionId, cityId, districtId);
}
// ── Create schema ───────────────────────────────────────────────────────────── // ── Create schema ─────────────────────────────────────────────────────────────
// Mirrors branch_validator.js createBranchSchema (Zod) field-for-field. // Mirrors branch_validator.js createBranchSchema (Zod) field-for-field, plus
// the new Region/City/District hierarchy fields.
export const createBranchSchema = yup.object({ export const createBranchSchema = yup.object({
name: yup name: yup
@@ -26,17 +45,34 @@ export const createBranchSchema = yup.object({
.string() .string()
.optional(), .optional(),
city: yup // Step 3: Hierarchy ids — required on create. `city`/`state`/`district`
// (free-text) stay in the schema below ONLY as derived/display fields
// populated from the resolved hierarchy, not typed by the user anymore.
regionId: yup
.string()
.required("المنطقة مطلوبة"),
cityId: yup
.string() .string()
.required("المدينة مطلوبة"), .required("المدينة مطلوبة"),
state: yup districtId: yup
.string() .string()
.optional(), .required("الحي مطلوب")
.test(
"location-chain-consistency",
"الحي المختار لا يتبع المدينة/المنطقة المختارة",
locationChainTest,
),
district: yup // Step 4: Kept as plain strings — these are populated automatically from
.string() // the resolved RegionEntry/CityEntry/DistrictEntry names right before the
.optional(), // payload is built (see BranchFormModal submitHandler), so the API
// contract (Branch.city / Branch.state / Branch.district as strings)
// never changes even though the UI now drives selection via ids.
city: yup.string().optional(),
state: yup.string().optional(),
district: yup.string().optional(),
street: yup street: yup
.string() .string()
@@ -52,21 +88,32 @@ export const createBranchSchema = yup.object({
zipCode: yup zipCode: yup
.string() .string()
.matches(/^[0-9]{5}$/, { message: "الرمز البريدي يجب أن يتكون من 5 أرقام", excludeEmptyString: true })
.optional(), .optional(),
// Step 5: latitude/longitude are now auto-derived from the selected
// district (see getDistrictCoordinates) and rendered read-only in the
// form — the range check below stays as a defensive guard in case a
// future data-entry mistake ever puts an out-of-range value in the
// dataset itself, not because the user can type these manually anymore.
latitude: yup latitude: yup
.string() .string()
.test("is-number", "خط العرض غير صالح", v => !v || !isNaN(Number(v))) .test("is-number", "خط العرض غير صالح", (v) => !v || !isNaN(Number(v)))
.test("lat-range", "خط العرض يجب أن يكون بين -90 و 90", (v) => !v || (Number(v) >= -90 && Number(v) <= 90))
.optional(), .optional(),
longitude: yup longitude: yup
.string() .string()
.test("is-number", "خط الطول غير صالح", v => !v || !isNaN(Number(v))) .test("is-number", "خط الطول غير صالح", (v) => !v || !isNaN(Number(v)))
.test("lng-range", "خط الطول يجب أن يكون بين -180 و 180", (v) => !v || (Number(v) >= -180 && Number(v) <= 180))
.optional(), .optional(),
}); });
// ── Update schema ───────────────────────────────────────────────────────────── // ── Update schema ─────────────────────────────────────────────────────────────
// Mirrors updateBranchSchema = createBranchSchema.partial().extend({ isActive }) // Mirrors updateBranchSchema = createBranchSchema.partial().extend({ isActive })
// Step 6: Hierarchy ids become optional on update (a branch may be edited
// without touching its location), but IF districtId is provided, the same
// chain-consistency test still applies.
export const updateBranchSchema = yup.object({ export const updateBranchSchema = yup.object({
name: yup name: yup
@@ -88,17 +135,20 @@ export const updateBranchSchema = yup.object({
.string() .string()
.optional(), .optional(),
city: yup regionId: yup.string().optional(),
cityId: yup.string().optional(),
districtId: yup
.string() .string()
.optional(), .optional()
.test(
"location-chain-consistency",
"الحي المختار لا يتبع المدينة/المنطقة المختارة",
locationChainTest,
),
state: yup city: yup.string().optional(),
.string() state: yup.string().optional(),
.optional(), district: yup.string().optional(),
district: yup
.string()
.optional(),
street: yup street: yup
.string() .string()
@@ -114,16 +164,19 @@ export const updateBranchSchema = yup.object({
zipCode: yup zipCode: yup
.string() .string()
.matches(/^[0-9]{5}$/, { message: "الرمز البريدي يجب أن يتكون من 5 أرقام", excludeEmptyString: true })
.optional(), .optional(),
latitude: yup latitude: yup
.string() .string()
.test("is-number", "خط العرض غير صالح", v => !v || !isNaN(Number(v))) .test("is-number", "خط العرض غير صالح", (v) => !v || !isNaN(Number(v)))
.test("lat-range", "خط العرض يجب أن يكون بين -90 و 90", (v) => !v || (Number(v) >= -90 && Number(v) <= 90))
.optional(), .optional(),
longitude: yup longitude: yup
.string() .string()
.test("is-number", "خط الطول غير صالح", v => !v || !isNaN(Number(v))) .test("is-number", "خط الطول غير صالح", (v) => !v || !isNaN(Number(v)))
.test("lng-range", "خط الطول يجب أن يكون بين -180 و 180", (v) => !v || (Number(v) >= -180 && Number(v) <= 180))
.optional(), .optional(),
isActive: yup.boolean().optional(), isActive: yup.boolean().optional(),
@@ -137,6 +190,9 @@ export type BranchSchemaErrors = Partial<
| "email" | "email"
| "phone" | "phone"
| "country" | "country"
| "regionId"
| "cityId"
| "districtId"
| "city" | "city"
| "state" | "state"
| "district" | "district"