diff --git a/app/dashboard/audit/page.tsx b/app/dashboard/audit/page.tsx index 353cc3f..01fcbe8 100644 --- a/app/dashboard/audit/page.tsx +++ b/app/dashboard/audit/page.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useReducer, useState } from "react"; import { Alert, Spinner } from "@/src/Components/UI"; import { getStoredToken } from "@/src/lib/auth"; import { get } from "@/src/services/api"; +import Header from "@/src/Components/UI/Header"; // ── Types ────────────────────────────────────────────────────────────────── @@ -38,35 +39,58 @@ type Action = function reducer(s: State, a: Action): State { switch (a.type) { - case "LOAD_START": return { ...s, loading: true, error: null }; - case "LOAD_OK": return { ...s, loading: false, logs: a.logs, total: a.total, pages: a.pages }; - case "LOAD_ERR": return { ...s, loading: false, error: a.error }; - case "CLEAR_ERR": return { ...s, error: null }; - default: return s; + case "LOAD_START": + return { ...s, loading: true, error: null }; + case "LOAD_OK": + return { + ...s, + loading: false, + logs: a.logs, + total: a.total, + pages: a.pages, + }; + case "LOAD_ERR": + return { ...s, loading: false, error: a.error }; + case "CLEAR_ERR": + return { ...s, error: null }; + default: + return s; } } // ── Action badge ─────────────────────────────────────────────────────────── -const ACTION_COLORS: Record = { +const ACTION_COLORS: Record< + string, + { bg: string; color: string; border: string } +> = { CREATE: { bg: "#DCFCE7", color: "#166534", border: "#BBF7D0" }, UPDATE: { bg: "#EFF6FF", color: "#1D4ED8", border: "#BFDBFE" }, DELETE: { bg: "#FEF2F2", color: "#DC2626", border: "#FECACA" }, - LOGIN: { bg: "#F5F3FF", color: "#5B21B6", border: "#DDD6FE" }, + LOGIN: { bg: "#F5F3FF", color: "#5B21B6", border: "#DDD6FE" }, LOGOUT: { bg: "#F1F5F9", color: "#475569", border: "#E2E8F0" }, }; function ActionBadge({ action }: { action: string }) { const upper = action?.toUpperCase() ?? ""; - const key = Object.keys(ACTION_COLORS).find(k => upper.includes(k)) ?? "CREATE"; + const key = + Object.keys(ACTION_COLORS).find((k) => upper.includes(k)) ?? "CREATE"; const cfg = ACTION_COLORS[key]; return ( - + {action} ); @@ -76,23 +100,39 @@ function ActionBadge({ action }: { action: string }) { export default function AuditPage() { const [state, dispatch] = useReducer(reducer, { - logs: [], loading: true, total: 0, pages: 1, error: null, + logs: [], + loading: true, + total: 0, + pages: 1, + error: null, }); - const [page, setPage] = useState(1); - const [search, setSearch] = useState(""); - const [module, setModule] = useState(""); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [module, setModule] = useState(""); const loadLogs = useCallback(async (p: number, q: string, mod: string) => { dispatch({ type: "LOAD_START" }); try { const token = getStoredToken(); const params = new URLSearchParams({ page: String(p), limit: "15" }); - if (q) params.set("search", q); + if (q) params.set("search", q); if (mod) params.set("module", mod); const res = await get<{ - data: { data: AuditLog[]; meta?: { total: number; pages: number }; pagination?: { total: number; pages: number } }; + data: { + data: AuditLog[]; + meta?: { total: number; pages: number }; + pagination?: { total: number; pages: number }; + }; }>(`audit?${params}`, token); - const payload = (res as unknown as { data: { data: AuditLog[]; meta?: { total: number; pages: number }; pagination?: { total: number; pages: number } } }).data; + const payload = ( + res as unknown as { + data: { + data: AuditLog[]; + meta?: { total: number; pages: number }; + pagination?: { total: number; pages: number }; + }; + } + ).data; dispatch({ type: "LOAD_OK", logs: payload.data ?? [], @@ -100,59 +140,72 @@ export default function AuditPage() { pages: payload.meta?.pages ?? payload.pagination?.pages ?? 1, }); } catch { - dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل سجل التدقيق. يرجى المحاولة مجدداً." }); + dispatch({ + type: "LOAD_ERR", + error: "تعذّر تحميل سجل التدقيق. يرجى المحاولة مجدداً.", + }); } }, []); - useEffect(() => { loadLogs(page, search, module); }, [page, search, module, loadLogs]); + useEffect(() => { + loadLogs(page, search, module); + }, [page, search, module, loadLogs]); const cardStyle: React.CSSProperties = { - borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", - background: "var(--color-surface)", overflow: "hidden", boxShadow: "var(--shadow-card)", + borderRadius: "var(--radius-xl)", + border: "1px solid var(--color-border)", + background: "var(--color-surface)", + overflow: "hidden", + boxShadow: "var(--shadow-card)", }; const thStyle: React.CSSProperties = { - padding: "0.75rem 1.5rem", fontSize: 11, fontWeight: 700, - textTransform: "uppercase", letterSpacing: "0.2em", - color: "var(--color-text-muted)", background: "var(--color-surface-muted)", + padding: "0.75rem 1.5rem", + fontSize: 11, + fontWeight: 700, + textTransform: "uppercase", + letterSpacing: "0.2em", + color: "var(--color-text-muted)", + background: "var(--color-surface-muted)", borderBottom: "1px solid var(--color-border)", }; return ( -
- +
{/* Header */} -
-

- الامتثال والمراجعة -

-
-
-

سجل التدقيق

-

- إجمالي {state.total} سجل -

-
-
- { setSearch(e.target.value); setPage(1); }} dir="rtl" - style={{ width: 220, height: 40, padding: "0 0.75rem", borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, color: "var(--color-text-primary)", outline: "none", fontFamily: "var(--font-sans)" }} /> - -
-
-
+
- {state.error && dispatch({ type: "CLEAR_ERR" })} />} + {state.error && ( + dispatch({ type: "CLEAR_ERR" })} + /> + )} {/* Table */}
-
+
الإجراء الوحدة المستخدم @@ -161,37 +214,88 @@ export default function AuditPage() {
{state.loading ? ( -
+
جارٍ التحميل…
) : state.logs.length === 0 ? ( -

+

لا توجد سجلات مطابقة

) : (
    {state.logs.map((log, i) => ( -
  • +
  • {log.entityId && ( -

    +

    {log.entityId.slice(0, 12)}…

    )}
    - {log.module ?? "—"} - {log.userName ?? log.userId ?? "—"} - {log.ipAddress ?? "—"} - - {new Date(log.createdAt).toLocaleString("ar-SA", { dateStyle: "short", timeStyle: "short" })} + + {log.module ?? "—"} + + + {log.userName ?? log.userId ?? "—"} + + + {log.ipAddress ?? "—"} + + + {new Date(log.createdAt).toLocaleString("ar-SA", { + dateStyle: "short", + timeStyle: "short", + })}
  • ))} @@ -199,17 +303,55 @@ export default function AuditPage() { )} {state.pages > 1 && ( -
    +
    - صفحة {page} من {state.pages} + صفحة{" "} + + {page} + {" "} + من{" "} + + {state.pages} +
    {[ - { label: "السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 }, - { label: "التالي", action: () => setPage(p => Math.min(state.pages, p + 1)), disabled: page === state.pages }, - ].map(btn => ( - ))} @@ -219,4 +361,4 @@ export default function AuditPage() {
); -} \ No newline at end of file +} diff --git a/app/dashboard/branches/page.tsx b/app/dashboard/branches/page.tsx index 94730ec..fe53a04 100644 --- a/app/dashboard/branches/page.tsx +++ b/app/dashboard/branches/page.tsx @@ -6,6 +6,7 @@ import { BranchDetailModal, BranchTable ,BranchFormModal } from "@/src/Compone import { useBranches } from "@/src/hooks/useBranch"; import type { Branch, BranchFormData } from "@/src/types/branch"; import { ArchivedBranchesModal } from "@/src/Components/Branch/archive/ArchivedBranchesModal"; +import Header from "@/src/Components/UI/Header"; export default function BranchesPage() { @@ -88,79 +89,19 @@ export default function BranchesPage() {
{/* ── Page header ── */} -
-

- إدارة الفروع -

-
-
-

- الفروع -

-

- إجمالي {total} فرع -

-
- -
- {/* Search input */} -
- - - - handleSearch(e.target.value)} - dir="rtl" - style={{ - width: "100%", height: 40, - paddingRight: 36, paddingLeft: 12, - borderRadius: "var(--radius-lg)", - border: "1px solid var(--color-border)", - background: "var(--color-surface)", - fontSize: 13, outline: "none", - fontFamily: "var(--font-sans)", - color: "var(--color-text-primary)", - }} - /> -
- - {/* Add branch button */} - -
-
-
+
{}} + title="الفروع" + mainTitle="إدارة الفروع" + setPage={setPage} + name={"فرع"} + isAudit={false} + onAdd={() => setFormTarget(null)} + /> {/* General load error */} {error && } diff --git a/app/dashboard/cars/page.tsx b/app/dashboard/cars/page.tsx index 97f743d..709579d 100644 --- a/app/dashboard/cars/page.tsx +++ b/app/dashboard/cars/page.tsx @@ -11,6 +11,7 @@ import { useCarMaintenanceMutations } from "@/src/hooks/UseCarsMaintanance"; import { fmtDateShort, 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"; +import Header from "@/src/Components/UI/Header"; // ── Toast ───────────────────────────────────────────────────────────────────── @@ -308,76 +309,7 @@ export default function CarsPage() {
{/* ── Header ── */} -
-

- إدارة الأسطول -

-
-
-

- المركبات -

-

- إجمالي {total} مركبة في الأسطول -

-
- -
- {/* Search */} -
- - - - { setSearch(e.target.value); setPage(1); }} - dir="rtl" - style={{ - width: "100%", height: 40, paddingRight: 36, paddingLeft: 12, - borderRadius: "var(--radius-lg)", - border: "1px solid var(--color-border)", - background: "var(--color-surface)", - fontSize: 13, color: "var(--color-text-primary)", - outline: "none", fontFamily: "var(--font-sans)", - }} - /> -
- - {/* Add button */} - -
-
-
- +
{}} setPage={setPage} title="المركبات" mainTitle="إدارة المركبات" name="مركبة" isAudit={false} onAdd={() => setFormTarget(null)}/> {/* Error alert */} {error && setError(null)} />} diff --git a/app/dashboard/clients/page.tsx b/app/dashboard/clients/page.tsx index ed1ab26..e64e8a5 100644 --- a/app/dashboard/clients/page.tsx +++ b/app/dashboard/clients/page.tsx @@ -13,6 +13,7 @@ import { useClients } from "@/src/hooks/useClients"; import type { Client, ClientFormData } from "@/src/types/client"; import { ClientFormModal , ClientTable } from "@/src/Components/Client"; import { ArchivedClientsModal } from "@/src/Components/Client/archive/ArchivedClientsModal"; +import Header from "@/src/Components/UI/Header"; export default function ClientsPage() { @@ -97,142 +98,19 @@ export default function ClientsPage() {
{/* ── Page header ── */} -
-

- إدارة العملاء -

-
-
-

- العملاء -

-

- إجمالي{" "} - - {total} - {" "} - عميل -

-
- -
- {/* Search input */} -
- - - - - handleSearch(e.target.value)} - dir="rtl" - style={{ - width: "100%", - height: 40, - paddingRight: 36, - paddingLeft: 12, - borderRadius: "var(--radius-lg)", - border: "1px solid var(--color-border)", - background: "var(--color-surface)", - fontSize: 13, - outline: "none", - fontFamily: "var(--font-sans)", - color: "var(--color-text-primary)", - }} - /> -
- - {/* Add client button */} - -
-
-
+
{}} + title="العملاء" + mainTitle="إدارة العملاء" + setPage={setPage} + name={"عميل"} + isAudit={false} + onAdd={() => setFormTarget(null)} + /> {/* General load error */} {error && } diff --git a/app/dashboard/drivers/page.tsx b/app/dashboard/drivers/page.tsx index d42c735..1377c25 100644 --- a/app/dashboard/drivers/page.tsx +++ b/app/dashboard/drivers/page.tsx @@ -1,18 +1,31 @@ "use client"; import { useState, useCallback } from "react"; -import { Alert, Spinner, ArchiveButton, ConfirmDialog } from "@/src/Components/UI"; +import { + Alert, + Spinner, + ArchiveButton, + ConfirmDialog, +} from "@/src/Components/UI"; import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers"; import { useDrivers } from "@/src/hooks/useDriver"; -import { CreateDriverPayload, Driver, DRIVER_STATUS_MAP, UpdateDriverPayload } from "@/src/types/driver"; -import { DriverDetailPanel, DriverFormModal , } from "@/src/Components/Driver"; +import { + CreateDriverPayload, + Driver, + DRIVER_STATUS_MAP, + UpdateDriverPayload, +} from "@/src/types/driver"; +import { DriverDetailPanel, DriverFormModal } from "@/src/Components/Driver"; +import Header from "@/src/Components/UI/Header"; // ── Helpers ─────────────────────────────────────────────────────────────────── function fmtDate(iso?: string | null): string { if (!iso) return "—"; return new Date(iso).toLocaleDateString("ar-SA", { - year: "numeric", month: "short", day: "numeric", + year: "numeric", + month: "short", + day: "numeric", }); } @@ -60,21 +73,31 @@ const iconBtnBase: React.CSSProperties = { export default function DriversPage() { const { - drivers, loading, error, total, pages, page, - search, setPage, handleSearch, clearError, - createDriver, updateDriver, deleteDriver, + drivers, + loading, + error, + total, + pages, + page, + search, + setPage, + handleSearch, + clearError, + createDriver, + updateDriver, + deleteDriver, notification, } = useDrivers(); // ── Panel / modal state ─────────────────────────────────────────────────── const [selectedDriverId, setSelectedDriverId] = useState(null); - const [formDriver, setFormDriver] = useState(null); - const [deleteTarget, setDeleteTarget] = useState(null); - const [deleting, setDeleting] = useState(false); + const [formDriver, setFormDriver] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); // Bumped after a successful edit to force the detail panel to re-fetch - const [panelRefreshKey, setPanelRefreshKey] = useState(0); + const [panelRefreshKey, setPanelRefreshKey] = useState(0); // Archive browser modal open/closed - const [archiveOpen, setArchiveOpen] = useState(false); + const [archiveOpen, setArchiveOpen] = useState(false); // ── Handlers ────────────────────────────────────────────────────────────── const handleEdit = useCallback((driver: Driver) => { @@ -132,91 +155,29 @@ export default function DriversPage() { // ── Render ──────────────────────────────────────────────────────────────── return ( <> -
- +
{/* ── Header ── */} -
-

- إدارة الكوادر -

-
-
-
-

- السائقون -

-

- إجمالي{" "} - {total}{" "} - سائق مسجل -

-
- -
- {/* Search */} -
- - - - handleSearch(e.target.value)} - dir="rtl" - style={{ - width: "100%", height: 40, paddingRight: 36, paddingLeft: 12, - borderRadius: "var(--radius-lg)", - border: "1px solid var(--color-border)", - background: "var(--color-surface)", - fontSize: 13, color: "var(--color-text-primary)", - outline: "none", fontFamily: "var(--font-sans)", - }} - /> -
- - {/* Add button */} - -
-
-
-
+
{}} + setPage={setPage} + isAudit={false} + onAdd={() => setFormDriver("new")} + /> {/* ── Notifications ── */} {notification && ( )} - {error && ( - - )} + {error && } {/* ── Table ── */}
@@ -238,20 +199,40 @@ export default function DriversPage() {
{loading ? ( -
+
جارٍ التحميل…
) : drivers.length === 0 ? ( -

+

لا توجد نتائج {search && `لـ "${search}"`}

) : (
    {drivers.map((d, i) => { - const statusCfg = DRIVER_STATUS_MAP[d.status] ?? DRIVER_STATUS_MAP.Inactive; - const licWarn = expirySoon(d.licenseExpiry); - const idWarn = expirySoon((d as Driver & { nationalIdExpiry?: string }).nationalIdExpiry); + const statusCfg = + DRIVER_STATUS_MAP[d.status] ?? DRIVER_STATUS_MAP.Inactive; + const licWarn = expirySoon(d.licenseExpiry); + const idWarn = expirySoon( + (d as Driver & { nationalIdExpiry?: string }) + .nationalIdExpiry, + ); return (
  • (e.currentTarget.style.background = "var(--color-surface-hover, #F8FAFC)")} - onMouseLeave={(e) => (e.currentTarget.style.background = i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent")} + onMouseEnter={(e) => + (e.currentTarget.style.background = + "var(--color-surface-hover, #F8FAFC)") + } + onMouseLeave={(e) => + (e.currentTarget.style.background = + i % 2 !== 0 + ? "var(--color-surface-muted)" + : "transparent") + } >
    -

    {d.name}

    -

    {d.phone}

    +

    + {d.name} +

    +

    + {d.phone} +

    - {d.branch?.name ?? "—"} - {d.nationality ?? "—"} - - {licWarn && "⚠ "}{fmtDate(d.licenseExpiry)} + + {d.branch?.name ?? "—"} - - {idWarn && "⚠ "}{fmtDate((d as Driver & { nationalIdExpiry?: string }).nationalIdExpiry)} + + {d.nationality ?? "—"} - - + + {licWarn && "⚠ "} + {fmtDate(d.licenseExpiry)} + + + {idWarn && "⚠ "} + {fmtDate( + (d as Driver & { nationalIdExpiry?: string }) + .nationalIdExpiry, + )} + + + {statusCfg.label} @@ -305,7 +354,10 @@ export default function DriversPage() { type="button" aria-label="تعديل السائق" title="تعديل" - onClick={(e) => { e.stopPropagation(); handleEdit(d); }} + onClick={(e) => { + e.stopPropagation(); + handleEdit(d); + }} style={{ ...iconBtnBase, border: "1px solid var(--color-brand-200)", @@ -313,7 +365,14 @@ export default function DriversPage() { color: "var(--color-brand-600)", }} > - + @@ -323,7 +382,10 @@ export default function DriversPage() { type="button" aria-label="حذف السائق" title="حذف" - onClick={(e) => { e.stopPropagation(); handleDelete(d); }} + onClick={(e) => { + e.stopPropagation(); + handleDelete(d); + }} style={{ ...iconBtnBase, border: "1px solid #FECACA", @@ -331,7 +393,14 @@ export default function DriversPage() { color: "#DC2626", }} > - + @@ -350,21 +419,36 @@ export default function DriversPage() {
    صفحة{" "} - {page}{" "} + + {page} + {" "} من{" "} - {pages} + + {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 => ( + { + 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) => ( -
    -
    -
-
{/* ── Notifications: page-level error banner (e.g. failed list load) dismissible via clearError() from the hook. Action-result diff --git a/app/dashboard/roles/page.tsx b/app/dashboard/roles/page.tsx index 04adb45..fa91521 100644 --- a/app/dashboard/roles/page.tsx +++ b/app/dashboard/roles/page.tsx @@ -7,6 +7,7 @@ import { RoleFormModal,RoleDetailModal } from "@/src/Components/role"; import { useRoles } from "@/src/hooks/useRole"; import { Role, RoleFormData } from "@/src/types/role"; import { ArchivedRolesModal } from "@/src/Components/role/archive/ArchivedRolesModal"; +import Header from "@/src/Components/UI/Header"; export default function RolesPage() { @@ -91,78 +92,17 @@ export default function RolesPage() {
{/* Page header */} -
-

- إدارة الصلاحيات -

-
-
-

- الأدوار -

-

- إجمالي {total} دور -

-
- -
- {/* Search */} -
- - - - handleSearch(e.target.value)} - dir="rtl" - style={{ - width: "100%", height: 40, - paddingRight: 36, paddingLeft: 12, - borderRadius: "var(--radius-lg)", - border: "1px solid var(--color-border)", - background: "var(--color-surface)", - fontSize: 13, outline: "none", - fontFamily: "var(--font-sans)", - color: "var(--color-text-primary)", - }} - /> -
- - {/* Add role button */} - -
-
-
+
{}} + setPage={setPage} + isAudit={false} + onAdd={() => setFormTarget(null)}/> {/* Error alert */} {error && } diff --git a/app/dashboard/trips/page.tsx b/app/dashboard/trips/page.tsx index aaf109d..0138b8c 100644 --- a/app/dashboard/trips/page.tsx +++ b/app/dashboard/trips/page.tsx @@ -13,6 +13,7 @@ import type { UpdateTripPayload, } from "@/src/types/trip"; import { TRIP_STATUS_MAP } from "@/src/types/trip"; +import Header from "@/src/Components/UI/Header"; // ── Status badge — same visual pattern as Driver's status badge ───────────── @@ -232,224 +233,17 @@ export default function TripsPage() { }} > {/* ── Header ── */} -
-

- إدارة العمليات -

-
-
-
-

- الرحلات -

-

- إجمالي{" "} - - {total} - {" "} - رحلة مسجلة -

-
- -
- {/* Search */} -
- - - - - setSearchInput(e.target.value)} - dir="rtl" - style={{ - width: "100%", - height: 40, - paddingRight: 36, - paddingLeft: 12, - borderRadius: "var(--radius-lg)", - border: "1px solid var(--color-border)", - background: "var(--color-surface)", - fontSize: 13, - color: "var(--color-text-primary)", - outline: "none", - fontFamily: "var(--font-sans)", - }} - /> -
- - {/* Status filter */} - - - {/* Archive button */} - - - {/* Add button */} - -
-
-
-
- +
{}} + setPage={setPage} + isAudit={false} + onAdd={() => { setEditTrip(null); setShowForm(true); }}/> {/* ── Error handling: Alert banner for list/API errors ── Same pattern as the Driver page: a persistent banner tied to the hook's `error` state, dismissible via `clearError`. */} diff --git a/app/dashboard/users/page.tsx b/app/dashboard/users/page.tsx index 8af9c8a..b5ba82b 100644 --- a/app/dashboard/users/page.tsx +++ b/app/dashboard/users/page.tsx @@ -6,6 +6,7 @@ import { UserFormModal,UserDetailModal,UserTable } from "@/src/Components/U import { useUsers } from "@/src/hooks/useUser"; import type { User, UserFormData } from "@/src/types/user"; import { ArchivedUsersModal } from "@/src/Components/User/archive/Archivedusersmodal"; +import Header from "@/src/Components/UI/Header"; export default function UsersPage() { // ── Modal state ───────────────────────────────────────────────────────────── @@ -90,79 +91,17 @@ export default function UsersPage() {
{/* ── Page header ── */} -
-

- إدارة الفريق -

-
-
-

- المستخدمون -

-

- إجمالي {total} مستخدم -

-
- -
- {/* Search input */} -
- - - - handleSearch(e.target.value)} - dir="rtl" - style={{ - width: "100%", height: 40, - paddingRight: 36, paddingLeft: 12, - borderRadius: "var(--radius-lg)", - border: "1px solid var(--color-border)", - background: "var(--color-surface)", - fontSize: 13, outline: "none", - fontFamily: "var(--font-sans)", - color: "var(--color-text-primary)", - }} - /> -
- - {/* Add user button */} - -
-
-
+
{}} + setPage={setPage} + isAudit={false} + onAdd={() => setFormTarget(null)}/> {/* General load error */} {error && } diff --git a/src/Components/Driver/DriverFormModal.tsx b/src/Components/Driver/DriverFormModal.tsx index 9beb53c..c5c0367 100644 --- a/src/Components/Driver/DriverFormModal.tsx +++ b/src/Components/Driver/DriverFormModal.tsx @@ -1,12 +1,12 @@ "use client"; -import { useEffect, useRef, useState } from "react"; -import * as yup from "yup"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useForm, Controller } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; import { Alert, Spinner } from "../UI"; import { get } from "@/src/services/api"; import { getStoredToken } from "@/src/lib/auth"; import { createDriverSchema, updateDriverSchema } from "@/src/validations/driver.validator"; -import type { DriverSchemaErrors } from "@/src/validations/driver.validator"; import type { Driver, CreateDriverPayload, @@ -72,6 +72,32 @@ function toIsoDateTime(val: string): string { return `${val}T00:00:00.000Z`; } +// ── Form values shape ──────────────────────────────────────────────────────── + +interface DriverFormValues { + name: string; + phone: string; + email: string; + address: string; + nationality: string; + nationalIdType: NationalIdType | ""; + nationalId: string; + nationalIdExpiry: string; + gosiNumber: string; + licenseNumber: string; + licenseType: string; + licenseExpiry: string; + driverCardNumber: string; + driverCardType: DriverCardType | ""; + driverCardExpiry: string; + driverType: string; + branchId: string; + status: DriverStatus; + photo: File | null; + nationalPhoto: File | null; + driverCardPhoto: File | null; +} + // ── Props ──────────────────────────────────────────────────────────────────── interface DriverFormModalProps { @@ -104,161 +130,123 @@ export function DriverFormModal({ const token = getStoredToken(); get<{ data: { data: Branch[] } }>("branches?limit=100", token) .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); }) - .catch(() => { /* silently ignore */ }); + .catch(() => { + /* silently ignore */ + }); }, [branchesProp]); - // ── Form state ──────────────────────────────────────────────────────────── - const [name, setName] = useState(editDriver?.name ?? ""); - const [phone, setPhone] = useState(editDriver?.phone ?? ""); - const [email, setEmail] = useState(editDriver?.email ?? ""); - const [address, setAddress] = useState(editDriver?.address ?? ""); - const [nationality, setNationality] = useState(editDriver?.nationality ?? ""); - const [nationalIdType, setNationalIdType] = useState( - editDriver?.nationalIdType ?? "", - ); - const [nationalId, setNationalId] = useState( - (editDriver as Driver & { nationalId?: string })?.nationalId ?? "", - ); - const [nationalIdExpiry, setNationalIdExpiry] = useState( - (editDriver as Driver & { nationalIdExpiry?: string })?.nationalIdExpiry?.slice(0, 10) ?? "", - ); - const [gosiNumber, setGosiNumber] = useState(editDriver?.gosiNumber ?? ""); - const [licenseNumber, setLicenseNumber] = useState(editDriver?.licenseNumber ?? ""); - const [licenseType, setLicenseType] = useState(editDriver?.licenseType ?? ""); - const [licenseExpiry, setLicenseExpiry] = useState( - editDriver?.licenseExpiry?.slice(0, 10) ?? "", - ); - const [driverCardNumber, setDriverCardNumber] = useState(editDriver?.driverCardNumber ?? ""); - const [driverCardType, setDriverCardType] = useState( - editDriver?.driverCardType ?? "", - ); - const [driverCardExpiry, setDriverCardExpiry] = useState( - editDriver?.driverCardExpiry?.slice(0, 10) ?? "", - ); - const [driverType, setDriverType] = useState(editDriver?.driverType ?? ""); - const [branchId, setBranchId] = useState( - (editDriver as Driver & { branchId?: string })?.branchId ?? "", - ); - const [status, setStatus] = useState(editDriver?.status ?? "Active"); + // ── react-hook-form ──────────────────────────────────────────────────────── + const { + register, + handleSubmit, + control, + setError, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: yupResolver(isNew ? createDriverSchema : updateDriverSchema) as never, + defaultValues: { + name: editDriver?.name ?? "", + phone: editDriver?.phone ?? "", + email: editDriver?.email ?? "", + address: editDriver?.address ?? "", + nationality: editDriver?.nationality ?? "", + nationalIdType: editDriver?.nationalIdType ?? "", + nationalId: (editDriver as Driver & { nationalId?: string })?.nationalId ?? "", + nationalIdExpiry: + (editDriver as Driver & { nationalIdExpiry?: string })?.nationalIdExpiry?.slice(0, 10) ?? "", + gosiNumber: editDriver?.gosiNumber ?? "", + licenseNumber: editDriver?.licenseNumber ?? "", + licenseType: editDriver?.licenseType ?? "", + licenseExpiry: editDriver?.licenseExpiry?.slice(0, 10) ?? "", + driverCardNumber: editDriver?.driverCardNumber ?? "", + driverCardType: editDriver?.driverCardType ?? "", + driverCardExpiry: editDriver?.driverCardExpiry?.slice(0, 10) ?? "", + driverType: editDriver?.driverType ?? "", + branchId: (editDriver as Driver & { branchId?: string })?.branchId ?? "", + status: editDriver?.status ?? "Active", + photo: null, + nationalPhoto: null, + driverCardPhoto: null, + }, + }); - // Photo state (new uploads only) - const [photo, setPhoto] = useState(null); - const [nationalPhoto, setNationalPhoto] = useState(null); - const [driverCardPhoto, setDriverCardPhoto] = useState(null); - - const [errors, setErrors] = useState({}); - const [saving, setSaving] = useState(false); const [apiError, setApiError] = useState(""); const firstRef = useRef(null); - useEffect(() => { firstRef.current?.focus(); }, []); useEffect(() => { - const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + firstRef.current?.focus(); + }, []); + useEffect(() => { + const h = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; window.addEventListener("keydown", h); return () => window.removeEventListener("keydown", h); }, [onClose]); - // ── Input error style ────────────────────────────────────────────────────── - const inputStyle = (field: keyof DriverSchemaErrors): React.CSSProperties => ({ + const withError = (hasError: boolean): React.CSSProperties => ({ ...inputBase, - ...(errors[field] ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}), + ...(hasError ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}), }); - const clearFieldError = (field: keyof DriverSchemaErrors) => - setErrors((p) => ({ ...p, [field]: undefined })); + // ── Submit ──────────────────────────────────────────────────────────────── - // ── Submit with yup validation ──────────────────────────────────────────── - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const submitHandler = useCallback( + async (data: DriverFormValues) => { + const payload: Record = { name: data.name, phone: data.phone }; + if (data.email) payload.email = data.email; + if (data.address) payload.address = data.address; + if (data.nationality) payload.nationality = data.nationality; + if (data.nationalIdType) payload.nationalIdType = data.nationalIdType; + if (data.nationalId) payload.nationalId = data.nationalId; + if (data.nationalIdExpiry) payload.nationalIdExpiry = toIsoDateTime(data.nationalIdExpiry); + if (data.gosiNumber) payload.gosiNumber = data.gosiNumber; + if (data.licenseNumber) payload.licenseNumber = data.licenseNumber; + if (data.licenseType) payload.licenseType = data.licenseType; + if (data.licenseExpiry) payload.licenseExpiry = toIsoDateTime(data.licenseExpiry); + if (data.driverCardNumber) payload.driverCardNumber = data.driverCardNumber; + if (data.driverCardType) payload.driverCardType = data.driverCardType; + if (data.driverCardExpiry) payload.driverCardExpiry = toIsoDateTime(data.driverCardExpiry); + if (data.driverType) payload.driverType = data.driverType; + if (data.branchId) payload.branchId = data.branchId; + if (!isNew) payload.status = data.status; + // File fields are excluded from the JSON diff unless actually chosen — + // matches the pre-refactor behavior exactly. + if (data.photo) payload.photo = data.photo; + if (data.nationalPhoto) payload.nationalPhoto = data.nationalPhoto; + if (data.driverCardPhoto) payload.driverCardPhoto = data.driverCardPhoto; - // Build the raw object to validate - const raw: Record = { - name: name || undefined, - phone: phone || undefined, - email: email || undefined, - address: address || undefined, - nationality: nationality || undefined, - nationalIdType: nationalIdType || undefined, - gosiNumber: gosiNumber || undefined, - licenseNumber: licenseNumber || undefined, - licenseType: licenseType || undefined, - licenseExpiry: licenseExpiry || undefined, - driverCardNumber: driverCardNumber || undefined, - driverCardType: driverCardType || undefined, - driverCardExpiry: driverCardExpiry || undefined, - driverType: driverType || undefined, - branchId: branchId || undefined, - ...(!isNew ? { status } : {}), - }; - - try { - const schema = isNew ? createDriverSchema : updateDriverSchema; - await schema.validate(raw, { abortEarly: false }); - } catch (err) { - if (err instanceof yup.ValidationError) { - const fieldErrors: DriverSchemaErrors = {}; - err.inner.forEach((e) => { - if (e.path) { - fieldErrors[e.path as keyof DriverSchemaErrors] = e.message; - } - }); - setErrors(fieldErrors); - return; + setApiError(""); + try { + const ok = await onSubmit(payload as unknown as CreateDriverPayload, isNew); + if (ok) { + onClose(); + } else { + setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." }); + setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."); + } + } catch (err) { + const message = err instanceof Error ? err.message : "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."; + setError("name", { message }); + setApiError(message); } - } + }, + [isNew, onSubmit, onClose, setError], + ); - const payload: Record = { name, phone }; - if (email) payload.email = email; - if (address) payload.address = address; - if (nationality) payload.nationality = nationality; - if (nationalIdType) payload.nationalIdType = nationalIdType; - if (nationalId) payload.nationalId = nationalId; - if (nationalIdExpiry) payload.nationalIdExpiry = toIsoDateTime(nationalIdExpiry); - if (gosiNumber) payload.gosiNumber = gosiNumber; - if (licenseNumber) payload.licenseNumber = licenseNumber; - if (licenseType) payload.licenseType = licenseType; - if (licenseExpiry) payload.licenseExpiry = toIsoDateTime(licenseExpiry); - if (driverCardNumber) payload.driverCardNumber = driverCardNumber; - if (driverCardType) payload.driverCardType = driverCardType; - if (driverCardExpiry) payload.driverCardExpiry = toIsoDateTime(driverCardExpiry); - if (driverType) payload.driverType = driverType; - if (branchId) payload.branchId = branchId; - if (!isNew) payload.status = status; - if (photo) payload.photo = photo; - if (nationalPhoto) payload.nationalPhoto = nationalPhoto; - if (driverCardPhoto) payload.driverCardPhoto = driverCardPhoto; + // ── Render ──────────────────────────────────────────────────────────────── - setSaving(true); - setApiError(""); - - try { - const ok = await onSubmit(payload as unknown as CreateDriverPayload, isNew); - if (ok) { - onClose(); - } else { - // onSubmit returned false without throwing — show generic fallback - setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."); - } - } catch (err) { - // onSubmit threw — surface the real API error message - const message = err instanceof Error ? err.message : "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."; - setApiError(message); - } finally { - setSaving(false); - } - }; - - // ── Render ───────────────────────────────────────────────────────────────── return (
{ if (e.target === e.currentTarget) onClose(); }} + onClick={(e) => { + if (e.target === e.currentTarget) onClose(); + }} style={{ position: "fixed", inset: 0, zIndex: 50, background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", @@ -305,7 +293,7 @@ export function DriverFormModal({ {/* Form */}
- {apiError && ( - setApiError("")} /> + {errors.name?.type === "manual" && ( + setApiError("")} /> )} {/* ── Section: Personal Info ── */} @@ -327,27 +315,27 @@ export function DriverFormModal({ الاسم الكامل * { setName(e.target.value); clearFieldError("name"); }} + style={withError(!!errors.name && errors.name.type !== "manual")} + {...register("name")} placeholder="محمد عبدالله" dir="rtl" autoComplete="off" /> - {errors.name && {errors.name}} + {errors.name && errors.name.type !== "manual" && ( + {errors.name.message} + )} {/* Phone — required */} {/* Email — optional */} @@ -355,14 +343,13 @@ export function DriverFormModal({ البريد الإلكتروني (اختياري) { setEmail(e.target.value); clearFieldError("email"); }} + {...register("email")} placeholder="example@mail.com" dir="ltr" /> - {errors.email && {errors.email}} + {errors.email && {errors.email.message}} {/* Nationality — optional */} @@ -370,13 +357,12 @@ export function DriverFormModal({ الجنسية (اختياري) { setNationality(e.target.value); clearFieldError("nationality"); }} + style={withError(!!errors.nationality)} + {...register("nationality")} placeholder="سعودي" dir="rtl" /> - {errors.nationality && {errors.nationality}} + {errors.nationality && {errors.nationality.message}} {/* Address — optional, full width */} @@ -384,13 +370,12 @@ export function DriverFormModal({ العنوان (اختياري) { setAddress(e.target.value); clearFieldError("address"); }} + style={withError(!!errors.address)} + {...register("address")} placeholder="الرياض، حي..." dir="rtl" /> - {errors.address && {errors.address}} + {errors.address && {errors.address.message}}
@@ -403,9 +388,8 @@ export function DriverFormModal({ نوع الهوية (اختياري) - {errors.nationalIdType && {errors.nationalIdType}} + {errors.nationalIdType && {errors.nationalIdType.message}} {/* National ID number */} {/* National ID expiry */} {/* GOSI — optional */} @@ -464,39 +431,27 @@ export function DriverFormModal({ رقم الرخصة (اختياري) { setLicenseNumber(e.target.value); clearFieldError("licenseNumber"); }} + style={withError(!!errors.licenseNumber)} + {...register("licenseNumber")} placeholder="LIC-XXXX" dir="ltr" /> - {errors.licenseNumber && {errors.licenseNumber}} + {errors.licenseNumber && {errors.licenseNumber.message}} {/* License type — optional */} {/* License expiry — optional, validated if provided */} @@ -508,13 +463,7 @@ export function DriverFormModal({ {/* Card type — optional */} @@ -522,9 +471,8 @@ export function DriverFormModal({ نوع البطاقة (اختياري) - {errors.driverCardType && {errors.driverCardType}} + {errors.driverCardType && {errors.driverCardType.message}} {/* Card expiry — optional, validated if provided */} @@ -541,12 +489,13 @@ export function DriverFormModal({ انتهاء البطاقة (اختياري) { setDriverCardExpiry(e.target.value); clearFieldError("driverCardExpiry"); }} + {...register("driverCardExpiry")} /> - {errors.driverCardExpiry && {errors.driverCardExpiry}} + {errors.driverCardExpiry && ( + {errors.driverCardExpiry.message} + )} @@ -559,9 +508,8 @@ export function DriverFormModal({ الفرع (اختياري) - {errors.branchId && {errors.branchId}} + {errors.branchId && {errors.branchId.message}} {/* Driver type — optional */} {/* Status — edit only */} {!isNew && (