diff --git a/app/dashboard/cars/page.tsx b/app/dashboard/cars/page.tsx index 1b7eaf9..9c9acbf 100644 --- a/app/dashboard/cars/page.tsx +++ b/app/dashboard/cars/page.tsx @@ -1,11 +1,12 @@ "use client"; -// app/(dashboard)/cars/page.tsx + import { useCallback, useState } from "react"; -import { Spinner, Alert } from "@/src/Components/UI"; -import { CarFormModal } from "@/src/Components/car/CarFormModal"; -import { CarDetailPanel } from "@/src/Components/car/CarDetailPanel"; -import { CarDeleteModal } from "@/src/Components/car/CarDeleteModal"; +import { Spinner, Alert, ArchiveButton } from "@/src/Components/UI"; +import { CarFormModal } from "@/src/Components/car/CarFormModal"; +import { CarDetailPanel } from "@/src/Components/car/CarDetailPanel"; +import { CarDeleteModal } from "@/src/Components/car/CarDeleteModal"; +import { ArchivedCarsModal } from "@/src/Components/car/archive/Archivedcarsmodal"; import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars"; import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/src/types/car"; import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } from "@/src/types/car"; @@ -169,6 +170,7 @@ export default function CarsPage() { const [detailId, setDetailId] = useState(null); const [formTarget, setFormTarget] = useState(false); // false = closed const [deleteTarget, setDeleteTarget] = useState(null); + const [archiveOpen, setArchiveOpen] = useState(false); const { toast, notify } = useToast(); @@ -226,6 +228,10 @@ export default function CarsPage() { /> )} + {archiveOpen && ( + setArchiveOpen(false)} /> + )} +
{/* ── Header ── */} @@ -400,6 +406,9 @@ export default function CarsPage() { )}
+ + {/* Floating button to open the archive browser */} + setArchiveOpen(true)} /> ); } \ No newline at end of file diff --git a/app/dashboard/drivers/page.tsx b/app/dashboard/drivers/page.tsx index 74e08bd..a70e960 100644 --- a/app/dashboard/drivers/page.tsx +++ b/app/dashboard/drivers/page.tsx @@ -1,9 +1,10 @@ "use client"; import { useState, useCallback } from "react"; -import { Alert, Spinner } from "@/src/Components/UI"; +import { Alert, Spinner, ArchiveButton } from "@/src/Components/UI"; import { DriverFormModal } from "@/src/Components/Driver/DriverFormModal"; import { DriverDeleteModal } from "@/src/Components/Driver/DriverDeleteModal"; +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 } from "@/src/Components/Driver/DriverDetailPanel"; @@ -74,6 +75,8 @@ export default function DriversPage() { const [deleting, setDeleting] = useState(false); // Bumped after a successful edit to force the detail panel to re-fetch const [panelRefreshKey, setPanelRefreshKey] = useState(0); + // Archive browser modal open/closed + const [archiveOpen, setArchiveOpen] = useState(false); // ── Handlers ────────────────────────────────────────────────────────────── const handleEdit = useCallback((driver: Driver) => { @@ -418,6 +421,14 @@ export default function DriversPage() { onConfirm={handleConfirmDelete} /> )} + + {/* ── Archive browser modal ── */} + {archiveOpen && ( + setArchiveOpen(false)} /> + )} + + {/* ── Floating button to open the archive browser ── */} + setArchiveOpen(true)} /> ); } \ No newline at end of file diff --git a/app/dashboard/orders/page.tsx b/app/dashboard/orders/page.tsx index d6a2266..5c1b043 100644 --- a/app/dashboard/orders/page.tsx +++ b/app/dashboard/orders/page.tsx @@ -3,10 +3,11 @@ import { useState, useCallback } from "react"; -import { Alert, Spinner, Toast } from "@/src/Components/UI"; -import { OrderFormModal } from "@/src/Components/Order/Orderformmodal"; -import { OrderDeleteModal } from "@/src/Components/Order/Orderdeletemodal"; +import { Alert, Spinner, Toast, ArchiveButton } from "@/src/Components/UI"; +import { OrderFormModal } from "@/src/Components/Order/OrderFormModal"; +import { OrderDeleteModal } from "@/src/Components/Order/OrderDeleteModal"; import { OrderDetailPanel, ORDER_STATUS_MAP } from "@/src/Components/Order/OrderDetailBanel"; +import { ArchivedOrdersModal } from "@/src/Components/Order/archive/ArchivedOrdersModal"; import { useOrders } from "@/src/hooks/useOrder"; import type { CreateOrderPayload, Order, UpdateOrderPayload } from "@/src/types/order"; @@ -81,6 +82,8 @@ export default function OrderComponent() { // Bumped after a successful edit/status-change to force the detail panel // to re-fetch — same trick as DriversPage's panelRefreshKey. const [panelRefreshKey, setPanelRefreshKey] = useState(0); + // Archive browser modal open/closed + const [archiveOpen, setArchiveOpen] = useState(false); // ── Handlers ───────────────────────────────────────────────────────── const handleEdit = useCallback((order: Order) => { @@ -447,12 +450,20 @@ export default function OrderComponent() { /> )} + {/* ── Archive browser modal ── */} + {archiveOpen && ( + setArchiveOpen(false)} /> + )} + {/* ── Toast: transient success/error feedback for create, update, delete, and status-change actions — sourced from useOrders()' `notification` state (see useOrders.ts notify()). This is the same toast used by the Driver pages via DriverDeleteModal's parent and useDriver.ts, kept consistent here. ── */} + + {/* Floating button to open the archive browser */} + setArchiveOpen(true)} /> ); } diff --git a/app/dashboard/trips/page.tsx b/app/dashboard/trips/page.tsx index 792ede4..85047b1 100644 --- a/app/dashboard/trips/page.tsx +++ b/app/dashboard/trips/page.tsx @@ -7,7 +7,8 @@ import { tripService } from "@/src/services/trip.service"; import { getStoredToken } from "@/src/lib/auth"; import { TripFormModal } from "@/src/Components/Trip/Tripformmodal"; import { TripDeleteModal } from "@/src/Components/Trip/Tripdeletemodal"; -import { Spinner } from "@/src/Components/UI"; +import { ArchivedTripList } from "@/src/Components/Trip/archive/ArchivedTripList"; +import { Spinner, ArchiveButton } from "@/src/Components/UI"; import { Toast, type ToastNotification } from "@/src/Components/UI/Toast"; import type { Trip, TripStatus, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip"; import { TRIP_STATUS_MAP } from "@/src/types/trip"; @@ -51,6 +52,67 @@ function StatusBadge({ status }: { status: TripStatus }) { ); } +// ── Archived trips modal ───────────────────────────────────────────────────── +// Lightweight wrapper modal around — mirrors the +// ArchivedUsersModal pattern used on the users page. + +function ArchivedTripsModal({ onClose }: { onClose: () => void }) { + const router = useRouter(); + + // close on Escape + useEffect(() => { + const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", h); + return () => window.removeEventListener("keydown", h); + }, [onClose]); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 70, + background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "flex-start", justifyContent: "center", + padding: "2rem 1rem", overflowY: "auto", + }} + > +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 920, + background: "var(--color-surface-sunken)", + borderRadius: "var(--radius-xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.2)", + overflow: "hidden", + }} + > + {/* header */} +
+

+ أرشيف الرحلات +

+ +
+ + {/* body */} +
+ router.push(`/dashboard/trips/${trip.id}`)} /> +
+
+
+ ); +} + // ── Page ───────────────────────────────────────────────────────────────────── export default function TripsPage() { @@ -93,6 +155,8 @@ export default function TripsPage() { const [editTrip, setEditTrip] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); + // Archive browser modal open/closed + const [archiveOpen, setArchiveOpen] = useState(false); // ── Create / update — calls tripService directly and notifies explicitly ── const handleSubmit = async ( @@ -146,22 +210,42 @@ export default function TripsPage() { إدارة جدولة الرحلات وتتبع حالتها

- +
+ + +
{/* ── Filters ── */} @@ -367,6 +451,9 @@ export default function TripsPage() { onConfirm={handleDelete} /> )} + {archiveOpen && ( + setArchiveOpen(false)} /> + )} {/* ── Toast ── */} diff --git a/app/dashboard/users/page.tsx b/app/dashboard/users/page.tsx index ccfbb02..1482c4e 100644 --- a/app/dashboard/users/page.tsx +++ b/app/dashboard/users/page.tsx @@ -1,13 +1,14 @@ "use client"; import { useState } from "react"; -import { Alert, Toast } from "@/src/Components/UI"; +import { Alert, Toast, ArchiveButton } from "@/src/Components/UI"; import { UserTable } from "@/src/Components/User/UserTable"; import { UserFormModal } from "@/src/Components/User/UserFormModal"; import { UserDetailModal } from "@/src/Components/User/UserDetailModal"; import { DeleteConfirmModal } from "@/src/Components/User/DeleteConfirmModal"; import { useUsers } from "@/src/hooks/useUser"; import type { User, UserFormData } from "@/src/types/user"; +import { ArchivedUsersModal } from "@/src/Components/User/archive/Archivedusersmodal"; export default function UsersPage() { // ── Modal state ───────────────────────────────────────────────────────────── @@ -16,6 +17,8 @@ export default function UsersPage() { const [deleteTarget, setDeleteTarget] = useState(null); // ID of user whose detail modal is open; null = closed const [viewUserId, setViewUserId] = useState(null); + // Archive browser modal open/closed + const [archiveOpen, setArchiveOpen] = useState(false); // Local submitting flag shown in DeleteConfirmModal spinner const [deleting, setDeleting] = useState(false); @@ -40,9 +43,9 @@ export default function UsersPage() { const handleDeleteConfirm = async () => { if (!deleteTarget || deleting) return; setDeleting(true); - + const ok = await deleteUser(deleteTarget.id); - + setDeleting(false); if (ok) setDeleteTarget(null); }; @@ -84,6 +87,11 @@ export default function UsersPage() { /> )} + {/* Archive browser modal */} + {archiveOpen && ( + setArchiveOpen(false)} /> + )} +
{/* ── Page header ── */} @@ -178,6 +186,9 @@ export default function UsersPage() { onPageChange={setPage} />
+ + {/* Floating button to open the archive browser */} + setArchiveOpen(true)} /> ); } \ No newline at end of file diff --git a/src/Components/Driver/archive/ArchivedDriverDetailModal.tsx b/src/Components/Driver/archive/ArchivedDriverDetailModal.tsx new file mode 100644 index 0000000..22a7e72 --- /dev/null +++ b/src/Components/Driver/archive/ArchivedDriverDetailModal.tsx @@ -0,0 +1,301 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Spinner } from "../../UI"; +import { PhotoCard } from "../DriverPhotos"; +import { getStoredToken } from "@/src/lib/auth"; +import { archivedDriverService } from "@/src/services/archive/archivedDriver.service"; +import { + DRIVER_STATUS_MAP, + DRIVER_CARD_TYPE_MAP, + NATIONAL_ID_TYPE_MAP, +} from "@/src/types/driver"; +import type { ArchivedDriver, DriverStatusHistoryEntry } from "@/src/types/driver"; + +interface ArchivedDriverDetailModalProps { + driverId: string; + onClose: () => void; +} + +// ── small helper components ─────────────────────────────────────────────────── + +/** Renders a single label/value row inside the detail body. */ +function DetailRow({ label, value }: { label: string; value?: string | null }) { + return ( +
+ + {label} + + + {value || "—"} + +
+ ); +} + +function Avatar({ name }: { name: string }) { + const initials = name.trim().split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase(); + return ( +
+ {initials} +
+ ); +} + +/** + * Modal showing the full profile of a single archived driver, including + * documents/photos and status history. Fetches `getById` and + * `getStatusHistory` in parallel on mount. + */ +export function ArchivedDriverDetailModal({ driverId, onClose }: ArchivedDriverDetailModalProps) { + const [driver, setDriver] = useState(null); + const [history, setHistory] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + // fetch archived driver details + status history on mount + useEffect(() => { + let cancelled = false; + (async () => { + try { + const token = getStoredToken(); + const [driverRes, historyRes] = await Promise.all([ + archivedDriverService.getById(driverId, token), + archivedDriverService.getStatusHistory(driverId, token), + ]); + if (!cancelled) { + setDriver(driverRes.data); + setHistory(historyRes.data ?? []); + } + } catch { + if (!cancelled) setError("تعذّر تحميل بيانات السائق المؤرشف. يرجى المحاولة لاحقاً."); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [driverId]); + + // ── helpers ─────────────────────────────────────────────────────────────── + const fmt = (iso?: string | null) => + iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null; + + const statusConfig = driver ? DRIVER_STATUS_MAP[driver.status] : null; + + // ── render ──────────────────────────────────────────────────────────────── + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 55, + background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", + }} + > +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 560, + background: "var(--color-surface)", + borderRadius: "var(--radius-2xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.18)", + overflow: "hidden", + display: "flex", flexDirection: "column", + }} + > + {/* ── header ── */} +
+
+

+ سائق مؤرشف +

+

+ {driver?.name ?? "عرض السائق"} +

+
+ +
+ + {/* ── body ── */} +
+ + {/* loading */} + {loading && ( +
+ + جارٍ التحميل… +
+ )} + + {/* error */} + {!loading && error && ( +
+ {error} +
+ )} + + {/* content */} + {!loading && driver && ( +
+ + {/* avatar + name + status row */} +
+ +
+

{driver.name}

+ {driver.userName && ( +

+ @{driver.userName} +

+ )} + {statusConfig && ( + + + {statusConfig.label} + + )} +
+
+ + {/* core info */} + + + + + + + + + + + + + + + + + {/* photos */} +

+ الصور والمستندات +

+
+ + + +
+ + {/* status history */} + {history.length > 0 && ( + <> +

+ سجل الحالات +

+
+ {history.map((h) => { + const s = DRIVER_STATUS_MAP[h.status] ?? DRIVER_STATUS_MAP.Active; + return ( +
+
+ {s.label} + {h.reason && ( + + — {h.reason} + + )} +
+ {fmt(h.createdAt)} +
+ ); + })} +
+ + )} +
+ )} +
+ + {/* ── footer ── */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/Driver/archive/ArchivedDriverTable.tsx b/src/Components/Driver/archive/ArchivedDriverTable.tsx new file mode 100644 index 0000000..9108bea --- /dev/null +++ b/src/Components/Driver/archive/ArchivedDriverTable.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { Spinner } from "../../UI"; +import { DRIVER_STATUS_MAP } from "@/src/types/driver"; +import type { ArchivedDriver } from "@/src/types/driver"; + +// ── status badge ───────────────────────────────────────────────────────────── +function StatusBadge({ status }: { status: ArchivedDriver["status"] }) { + const cfg = DRIVER_STATUS_MAP[status] ?? DRIVER_STATUS_MAP.Inactive; + return ( + + + {cfg.label} + + ); +} + +// ── icon button ────────────────────────────────────────────────────────────── +function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) { + return ( + + ); +} + +// ── card / header styles ───────────────────────────────────────────────────── +const cardStyle: React.CSSProperties = { + 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)", + borderBottom: "1px solid var(--color-border)", +}; + +const ROW_GRID_COLUMNS = "2fr 1.5fr 1fr 1fr 1fr 100px"; + +// ── Props ──────────────────────────────────────────────────────────────────── +interface ArchivedDriverTableProps { + drivers: ArchivedDriver[]; + loading: boolean; + search: string; + page: number; + pages: number; + onView: (driver: ArchivedDriver) => void; + onPageChange: (p: number) => void; +} + +/** + * Renders the paginated table of archived drivers, including loading, + * empty, and error-free states. Mirrors ArchivedUserTable for consistency. + */ +export function ArchivedDriverTable({ drivers, loading, search, page, pages, onView, onPageChange }: ArchivedDriverTableProps) { + return ( +
+ {/* column headers */} +
+ الاسم + اسم المستخدم + الحالة + تاريخ الإنشاء + آخر تحديث + إجراءات +
+ + {/* loading state */} + {loading ? ( +
+ + جارٍ التحميل… +
+ ) : drivers.length === 0 ? ( + /* empty state */ +
+

+ {search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد سائقون في الأرشيف."} +

+
+ ) : ( + /* data rows */ +
    + {drivers.map((d, i) => ( +
  • onView(d)} style={{ + display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS, + alignItems: "center", gap: "0.5rem", padding: "0.875rem 1.5rem", + borderBottom: "1px solid var(--color-border)", + background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent", + fontSize: 13, cursor: "pointer", + }}> +
    +

    {d.name}

    +

    {d.phone}

    +
    + {d.userName ?? "—"} + + + {new Date(d.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} + + + {new Date(d.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} + +
    + {/* view only — delete/restore not implemented yet, same as users */} + onView(d)}> + + + + + +
    +
  • + ))} +
+ )} + + {/* pagination */} + {pages > 1 && ( +
+ + صفحة {page} من {pages} + +
+ {[ + { label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, + { label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, + ].map(btn => ( + + ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/Components/Driver/archive/ArchivedDrivers.tsx b/src/Components/Driver/archive/ArchivedDrivers.tsx new file mode 100644 index 0000000..e893a7a --- /dev/null +++ b/src/Components/Driver/archive/ArchivedDrivers.tsx @@ -0,0 +1,119 @@ +"use client"; + +import { useState } from "react"; +import { Alert } from "../../UI"; +import { ArchivedDriverTable } from "./ArchivedDriverTable"; +import { ArchivedDriverDetailModal } from "./ArchivedDriverDetailModal"; +import { useArchivedDrivers } from "@/src/hooks/archive/useArchivedDrivers"; + +interface ArchivedDriversProps { + onClose: () => void; +} + +/** + * Top-level modal for browsing archived drivers. Wires the + * useArchivedDrivers hook to the table and detail modal, mirroring + * ArchivedUsersModal's structure and behavior for drivers. + */ +export function ArchivedDrivers({ onClose }: ArchivedDriversProps) { + const [viewDriverId, setViewDriverId] = useState(null); + + const { + drivers, loading, total, pages, error, + page, search, + setPage, handleSearch, clearError, + } = useArchivedDrivers(); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 70, + background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "flex-start", justifyContent: "center", + padding: "2rem 1rem", overflowY: "auto", + }} + > + {viewDriverId && ( + setViewDriverId(null)} /> + )} + +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 920, + background: "var(--color-surface-sunken)", + borderRadius: "var(--radius-xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.2)", + overflow: "hidden", + }} + > + {/* header */} +
+
+

+ الأرشيف +

+

+ السائقون المؤرشفون + + ({total}) + +

+
+ +
+ + {/* body */} +
+ {/* 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)", + }} + /> +
+ + {error && } + + setViewDriverId(driver.id)} + onPageChange={setPage} + /> +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/Order/archive/ArchivedOrderDetailModal.tsx b/src/Components/Order/archive/ArchivedOrderDetailModal.tsx new file mode 100644 index 0000000..c57a98d --- /dev/null +++ b/src/Components/Order/archive/ArchivedOrderDetailModal.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { ORDER_STATUS_MAP } from "../OrderDetailBanel"; +import type { ArchivedOrder } from "@/src/types/order"; + +interface ArchivedOrderDetailModalProps { + order: ArchivedOrder; + onClose: () => void; +} + +function DetailRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} + +function fmtDate(iso?: string | null): string { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }); +} + +export function ArchivedOrderDetailModal({ order, onClose }: ArchivedOrderDetailModalProps) { + const s = ORDER_STATUS_MAP[order.currentStatus] ?? ORDER_STATUS_MAP.Created; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ position: "fixed", inset: 0, zIndex: 80, background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem" }}> +
e.stopPropagation()} + style={{ width: "100%", maxWidth: 480, background: "var(--color-surface)", borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border)", boxShadow: "0 24px 64px rgba(0,0,0,.18)", overflow: "hidden" }}> +
+
+

طلب مؤرشف

+

+ {order.shipmentNumber} +

+
+ +
+ +
+ + + {s.label} + + + + + + + + + + {order.statusHistory && order.statusHistory.length > 0 && ( +
+

سجل الحالات

+ {order.statusHistory.map(h => { + const hs = ORDER_STATUS_MAP[h.status] ?? ORDER_STATUS_MAP.Created; + return ( +
+ {hs.label}{h.reason ? ` — ${h.reason}` : ""} + {fmtDate(h.createdAt)} +
+ ); + })} +
+ )} +
+ +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/Order/archive/ArchivedOrderTable.tsx b/src/Components/Order/archive/ArchivedOrderTable.tsx new file mode 100644 index 0000000..33cd259 --- /dev/null +++ b/src/Components/Order/archive/ArchivedOrderTable.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { Spinner } from "../../UI"; +import { ORDER_STATUS_MAP } from "../OrderDetailBanel"; +import type { ArchivedOrder } from "@/src/types/order"; + +function fmtAmount(n?: number | null): string { + if (n == null) return "—"; + return `${n.toFixed(2)} ر.س`; +} + +const cardStyle: React.CSSProperties = { + 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)", + borderBottom: "1px solid var(--color-border)", +}; +const ROW_GRID = "1.6fr 1.6fr 1fr 1fr 100px"; + +interface ArchivedOrderTableProps { + orders: ArchivedOrder[]; + loading: boolean; + search: string; + page: number; + pages: number; + onView: (order: ArchivedOrder) => void; + onPageChange: (p: number) => void; +} + +export function ArchivedOrderTable({ orders, loading, search, page, pages, onView, onPageChange }: ArchivedOrderTableProps) { + return ( +
+
+ رقم الشحنة + المستلم + المبلغ + الحالة + إجراءات +
+ + {loading ? ( +
+ + جارٍ التحميل… +
+ ) : orders.length === 0 ? ( +
+

+ {search ? `لا توجد نتائج لـ "${search}"` : "لا توجد طلبات في الأرشيف."} +

+
+ ) : ( +
    + {orders.map((o, i) => { + const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created; + return ( +
  • + + {o.shipmentNumber} + +
    +

    {o.recipientName}

    +

    {o.recipientPhone}

    +
    + + {fmtAmount(o.subTotal)} + + + + {s.label} + +
    + +
    +
  • + ); + })} +
+ )} + + {pages > 1 && ( +
+ + صفحة {page} من {pages} + +
+ {[ + { label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, + { label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, + ].map(btn => ( + + ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/Components/Order/archive/ArchivedOrdersModal.tsx b/src/Components/Order/archive/ArchivedOrdersModal.tsx new file mode 100644 index 0000000..0f85c1d --- /dev/null +++ b/src/Components/Order/archive/ArchivedOrdersModal.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useState } from "react"; +import { Alert } from "../../UI"; +import { ArchivedOrderTable } from "./ArchivedOrderTable"; +import { ArchivedOrderDetailModal } from "./ArchivedOrderDetailModal"; +import { useArchivedOrders } from "@/src/hooks/archive/useArchivedOrders"; +import type { ArchivedOrder } from "@/src/types/order"; + +interface ArchivedOrdersModalProps { + onClose: () => void; +} + +export function ArchivedOrdersModal({ onClose }: ArchivedOrdersModalProps) { + const [viewOrder, setViewOrder] = useState(null); + + const { + orders, loading, total, pages, error, + page, search, + setPage, handleSearch, clearError, + } = useArchivedOrders(); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 70, + background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "flex-start", justifyContent: "center", + padding: "2rem 1rem", overflowY: "auto", + }} + > + {viewOrder && ( + setViewOrder(null)} /> + )} + +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 920, + background: "var(--color-surface-sunken)", + borderRadius: "var(--radius-xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.2)", + overflow: "hidden", + }} + > + {/* header */} +
+
+

+ الأرشيف +

+

+ الطلبات المؤرشفة + + ({total}) + +

+
+ +
+ + {/* body */} +
+ {/* 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)", + }} + /> +
+ + {error && } + + setViewOrder(order)} + onPageChange={setPage} + /> +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/Trip/archive/ArchivedTripList.tsx b/src/Components/Trip/archive/ArchivedTripList.tsx new file mode 100644 index 0000000..cc6a9f6 --- /dev/null +++ b/src/Components/Trip/archive/ArchivedTripList.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { Spinner, Alert, EmptyState, Badge } from "@/src/Components/UI"; +import { useArchivedTrips } from "@/src/hooks/archive/useArchivedTrips"; +import { TRIP_STATUS_MAP } from "@/src/types/trip"; +import type { Trip } from "@/src/types/trip"; + +interface ArchivedTripListProps { + /** Called when the user clicks a row to view trip details */ + onView?: (trip: Trip) => void; +} + +// ── Status badge — reuses the existing trip status color map ──────────────── +function TripStatusBadge({ status }: { status: Trip["status"] }) { + const s = TRIP_STATUS_MAP[status]; + return ( + + + {s.label} + + ); +} + +// ── Date formatting helper ─────────────────────────────────────────────────── +function fmtDate(iso?: string | null): string { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" }); +} + +/** + * ArchivedTripList + * Displays a paginated, searchable list of archived trips. + * Fetches data via useArchivedTrips (GET /trip/archived), handles loading, + * empty, and error states, and renders each trip with its key details. + * + * @example + * setViewTripId(trip.id)} /> + */ +export function ArchivedTripList({ onView }: ArchivedTripListProps) { + const { + trips, loading, total, pages, page, search, error, + setPage, handleSearch, clearError, + } = useArchivedTrips(); + + return ( +
+ {/* ── Header: count + search ── */} +
+

+ الرحلات المؤرشفة + + ({total}) + +

+ +
+ + + + handleSearch(e.target.value)} + className="h-10 w-full rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface)] pr-9 pl-3 text-[13px] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-brand-600)] focus:ring-2 focus:ring-[var(--color-brand-600)]/15" + /> +
+
+ + {/* ── API error feedback ── */} + {error && } + + {/* ── Card ── */} +
+ {/* column headers */} +
+ الرحلة + الحالة + البدء + الانتهاء + النقد المحصّل +
+ + {/* ── Loading state ── */} + {loading ? ( +
+ + جارٍ التحميل… +
+ ) : trips.length === 0 ? ( + /* ── Empty state ── */ + + ) : ( + /* ── Data rows ── */ +
    + {trips.map((trip, i) => ( +
  • onView?.(trip)} + className={`grid grid-cols-[2fr_1.5fr_1fr_1fr_1fr] items-center gap-2 border-b border-[var(--color-border)] px-6 py-3.5 text-[13px] transition-colors hover:bg-[var(--color-surface-muted)] ${ + onView ? "cursor-pointer" : "" + } ${i % 2 !== 0 ? "bg-[var(--color-surface-muted)]/40" : ""}`} + > +
    +

    {trip.title}

    +

    {trip.tripNumber}

    +
    +
    + +
    + + {fmtDate(trip.startTime)} + + + {fmtDate(trip.endTime)} + + + {trip.totalCashCollected != null + ? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س` + : "—"} + +
  • + ))} +
+ )} + + {/* ── Pagination ── */} + {pages > 1 && ( +
+ + صفحة {page} من{" "} + {pages} + +
+ + +
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/Components/UI/ArchiveButton.tsx b/src/Components/UI/ArchiveButton.tsx new file mode 100644 index 0000000..6b5e6d7 --- /dev/null +++ b/src/Components/UI/ArchiveButton.tsx @@ -0,0 +1,61 @@ +"use client"; + +interface ArchiveButtonProps { + onClick: () => void; + label?: string; + /** "floating" pins it bottom-right of the viewport; "inline" renders it in normal flow */ + variant?: "floating" | "inline"; +} + +export function ArchiveButton({ + onClick, + label = "الأرشيف", + variant = "floating", +}: ArchiveButtonProps) { + const base: React.CSSProperties = { + height: 44, + padding: "0 1.25rem", + borderRadius: "var(--radius-full)", + border: "none", + background: "#EA580C", // orange-600, matches existing danger/success accent pattern + fontSize: 13, + fontWeight: 700, + color: "#FFF", + cursor: "pointer", + display: "inline-flex", + alignItems: "center", + gap: 8, + fontFamily: "var(--font-sans)", + boxShadow: "0 4px 14px rgba(234,88,12,.35)", + whiteSpace: "nowrap", + transition: "transform 150ms, box-shadow 150ms", + }; + + const floatingStyle: React.CSSProperties = variant === "floating" + ? { + position: "fixed", + bottom: 24, + insetInlineEnd: 24, // logical property — respects RTL/LTR automatically + zIndex: 40, + } + : {}; + + return ( + + ); +} \ No newline at end of file diff --git a/src/Components/UI/index.ts b/src/Components/UI/index.ts index 1421a94..14a7cc6 100644 --- a/src/Components/UI/index.ts +++ b/src/Components/UI/index.ts @@ -21,4 +21,6 @@ export { Modal } from "./ModalProps"; export { ConfirmDialog } from "./ConfirmDialog"; export { PageHeader } from "./PageHeader"; export { EmptyState } from "./EmptyState"; -export { Badge } from "./Badge"; \ No newline at end of file +export { Badge } from "./Badge"; + +export { ArchiveButton } from "./ArchiveButton" \ No newline at end of file diff --git a/src/Components/User/archive/Archiveduserdetailmodal.tsx b/src/Components/User/archive/Archiveduserdetailmodal.tsx new file mode 100644 index 0000000..0bd9de1 --- /dev/null +++ b/src/Components/User/archive/Archiveduserdetailmodal.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Spinner } from "../../UI"; +import { getStoredToken } from "@/src/lib/auth"; +import { archivedUserService } from "@/src/services/archive/archivedUser.service"; +import type { ArchivedUser } from "@/src/types/user"; + +interface ArchivedUserDetailModalProps { + userId: string; + onClose: () => void; +} + +// ── small helper components ─────────────────────────────────────────────────── +function DetailRow({ label, value }: { label: string; value?: string | null }) { + return ( +
+ + {label} + + + {value || "—"} + +
+ ); +} + +function StatusBadge({ active }: { active: boolean }) { + return ( + + + {active ? "نشط" : "معطل"} + + ); +} + +function Avatar({ name }: { name: string }) { + const initials = name.trim().split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase(); + return ( +
+ {initials} +
+ ); +} + +// ── main component ──────────────────────────────────────────────────────────── +export function ArchivedUserDetailModal({ userId, onClose }: ArchivedUserDetailModalProps) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [onClose]); + + // fetch archived user details on mount + useEffect(() => { + let cancelled = false; + (async () => { + try { + const token = getStoredToken(); + const res = await archivedUserService.getById(userId, token); + if (!cancelled) setUser(res.data); + } catch { + if (!cancelled) setError("تعذّر تحميل بيانات المستخدم المؤرشف. يرجى المحاولة لاحقاً."); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [userId]); + + // ── helpers ─────────────────────────────────────────────────────────────── + const fmt = (iso?: string | null) => + iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null; + + // ── render ──────────────────────────────────────────────────────────────── + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 55, + background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", + }} + > +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 480, + background: "var(--color-surface)", + borderRadius: "var(--radius-2xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.18)", + overflow: "hidden", + display: "flex", flexDirection: "column", + }} + > + {/* ── header ── */} +
+
+

+ مستخدم مؤرشف +

+

+ {user?.name ?? "عرض المستخدم"} +

+
+ +
+ + {/* ── body ── */} +
+ + {/* loading */} + {loading && ( +
+ + جارٍ التحميل… +
+ )} + + {/* error */} + {!loading && error && ( +
+ {error} +
+ )} + + {/* content */} + {!loading && user && ( +
+ + {/* avatar + name + status row */} +
+ +
+

{user.name}

+ {user.userName && ( +

+ @{user.userName} +

+ )} +
+ +
+
+
+ + {/* detail rows */} + + + + +
+ )} +
+ + {/* ── footer ── */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/User/archive/Archivedusersmodal.tsx b/src/Components/User/archive/Archivedusersmodal.tsx new file mode 100644 index 0000000..f1e3703 --- /dev/null +++ b/src/Components/User/archive/Archivedusersmodal.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { useState } from "react"; +import { Alert } from "../../UI"; +import { ArchivedUserTable } from "./Archivedusertable"; +import { ArchivedUserDetailModal } from "./Archiveduserdetailmodal"; +import { useArchivedUsers } from "@/src/hooks/archive/useArchivedUsers"; + +interface ArchivedUsersModalProps { + onClose: () => void; +} + +export function ArchivedUsersModal({ onClose }: ArchivedUsersModalProps) { + const [viewUserId, setViewUserId] = useState(null); + + const { + users, loading, total, pages, error, + page, search, + setPage, handleSearch, clearError, + } = useArchivedUsers(); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 70, + background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "flex-start", justifyContent: "center", + padding: "2rem 1rem", overflowY: "auto", + }} + > + {viewUserId && ( + setViewUserId(null)} /> + )} + +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 920, + background: "var(--color-surface-sunken)", + borderRadius: "var(--radius-xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.2)", + overflow: "hidden", + }} + > + {/* header */} +
+
+

+ الأرشيف +

+

+ المستخدمون المؤرشفون + + ({total}) + +

+
+ +
+ + {/* body */} +
+ {/* 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)", + }} + /> +
+ + {error && } + + setViewUserId(user.id)} + onPageChange={setPage} + /> +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/User/archive/Archivedusertable.tsx b/src/Components/User/archive/Archivedusertable.tsx new file mode 100644 index 0000000..22f34f5 --- /dev/null +++ b/src/Components/User/archive/Archivedusertable.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { Spinner } from "../../UI"; +import type { ArchivedUser } from "@/src/types/user"; + +// ── status badge ───────────────────────────────────────────────────────────── +function StatusBadge({ active }: { active: boolean }) { + return ( + + + {active ? "نشط" : "معطل"} + + ); +} + +// ── icon button ────────────────────────────────────────────────────────────── +function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) { + return ( + + ); +} + +// ── card / header styles ───────────────────────────────────────────────────── +const cardStyle: React.CSSProperties = { + 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)", + borderBottom: "1px solid var(--color-border)", +}; + +// ── Props ──────────────────────────────────────────────────────────────────── +interface ArchivedUserTableProps { + users: ArchivedUser[]; + loading: boolean; + search: string; + page: number; + pages: number; + onView: (user: ArchivedUser) => void; + onPageChange: (p: number) => void; +} + +// ── main table ─────────────────────────────────────────────────────────────── +export function ArchivedUserTable({ users, loading, search, page, pages, onView, onPageChange }: ArchivedUserTableProps) { + return ( +
+ {/* column headers */} +
+ الاسم + اسم المستخدم + الحالة + تاريخ الإنشاء + آخر تحديث + إجراءات +
+ + {/* loading state */} + {loading ? ( +
+ + جارٍ التحميل… +
+ ) : users.length === 0 ? ( + /* empty state */ +
+

+ {search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون في الأرشيف."} +

+
+ ) : ( + /* data rows */ +
    + {users.map((u, i) => ( +
  • +
    +

    {u.name}

    +

    {u.phone}

    +
    + {u.userName ?? "—"} + + + {new Date(u.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} + + + {new Date(u.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })} + +
    + {/* view only — delete/restore not implemented yet */} + onView(u)}> + + + + + +
    +
  • + ))} +
+ )} + + {/* pagination */} + {pages > 1 && ( +
+ + صفحة {page} من {pages} + +
+ {[ + { label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, + { label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, + ].map(btn => ( + + ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/Components/car/archive/Archivedcardetailpanel.tsx b/src/Components/car/archive/Archivedcardetailpanel.tsx new file mode 100644 index 0000000..34f7325 --- /dev/null +++ b/src/Components/car/archive/Archivedcardetailpanel.tsx @@ -0,0 +1,149 @@ +"use client"; + +import { useEffect } from "react"; +import { Spinner } from "../../UI"; +import { STATUS_MAP, INS_MAP, fmtDate, isExpiringSoon } from "@/src/types/car"; +import type { Car, InsuranceStatus } from "@/src/types/car"; + +interface ArchivedCarDetailPanelProps { + car: Car | null; + loading: boolean; + error: string | null; + onClose: () => void; +} + +function DetailRow({ label, value, mono = false, warn = false }: { + label: string; value: string; mono?: boolean; warn?: boolean; +}) { + return ( +
+ {label} + + {warn && value !== "—" ? "⚠ " : ""}{value} + +
+ ); +} + +export function ArchivedCarDetailPanel({ car, loading, error, onClose }: ArchivedCarDetailPanelProps) { + useEffect(() => { + const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); }; + window.addEventListener("keydown", h); + return () => window.removeEventListener("keydown", h); + }, [onClose]); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 80, + background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem", + }} + > +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 480, + background: "var(--color-surface)", + borderRadius: "var(--radius-2xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.18)", + overflow: "hidden", + display: "flex", flexDirection: "column", + }} + > + {/* header */} +
+
+

+ مركبة مؤرشفة +

+

+ {car ? `${car.manufacturer} ${car.model}` : "عرض المركبة"} +

+
+ +
+ + {/* body */} +
+ {loading && ( +
+ + جارٍ التحميل… +
+ )} + + {!loading && error && ( +
+ {error} +
+ )} + + {!loading && car && ( + <> + {/* status badges */} +
+ {(() => { + const s = STATUS_MAP[car.currentStatus]; + return ( + + {s.label} + + ); + })()} + {car.insuranceStatus && (() => { + const ins = INS_MAP[car.insuranceStatus as InsuranceStatus]; + return ( + + تأمين: {ins.label} + + ); + })()} + + مؤرشفة + +
+ + + + + + + + + + + )} +
+ + {/* footer */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/car/archive/Archivedcarsmodal.tsx b/src/Components/car/archive/Archivedcarsmodal.tsx new file mode 100644 index 0000000..ab4c858 --- /dev/null +++ b/src/Components/car/archive/Archivedcarsmodal.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useState } from "react"; +import { Alert } from "../../UI"; +import { ArchivedCarTable } from "./Archivedcartable"; +import { ArchivedCarDetailPanel } from "./Archivedcardetailpanel"; +import { useArchivedCars } from "@/src/hooks/archive/Usearchivedcars"; +import type { Car } from "@/src/types/car"; + +interface ArchivedCarsModalProps { + onClose: () => void; +} + +export function ArchivedCarsModal({ onClose }: ArchivedCarsModalProps) { + const [viewCar, setViewCar] = useState(null); + + const { + cars, loading, total, pages, error, + page, search, + setPage, handleSearch, setError, + } = useArchivedCars(); + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + style={{ + position: "fixed", inset: 0, zIndex: 70, + background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)", + display: "flex", alignItems: "flex-start", justifyContent: "center", + padding: "2rem 1rem", overflowY: "auto", + }} + > + {viewCar && ( + setViewCar(null)} + /> + )} + +
e.stopPropagation()} + style={{ + width: "100%", maxWidth: 980, + background: "var(--color-surface-sunken)", + borderRadius: "var(--radius-xl)", + border: "1px solid var(--color-border)", + boxShadow: "0 24px 64px rgba(0,0,0,.2)", + overflow: "hidden", + }} + > + {/* header */} +
+
+

+ الأرشيف +

+

+ المركبات المؤرشفة + + ({total}) + +

+
+ +
+ + {/* body */} +
+ {/* 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)", + }} + /> +
+ + {error && setError(null)} />} + + setViewCar(car)} + onPageChange={setPage} + /> +
+
+
+ ); +} \ No newline at end of file diff --git a/src/Components/car/archive/Archivedcartable.tsx b/src/Components/car/archive/Archivedcartable.tsx new file mode 100644 index 0000000..8b89883 --- /dev/null +++ b/src/Components/car/archive/Archivedcartable.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { Spinner } from "../../UI"; +import { STATUS_MAP, fmtDateShort } from "@/src/types/car"; +import type { Car } from "@/src/types/car"; + +// ── card / header styles ───────────────────────────────────────────────────── +const cardStyle: React.CSSProperties = { + 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)", + borderBottom: "1px solid var(--color-border)", +}; + +// ── icon button ────────────────────────────────────────────────────────────── +function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) { + return ( + + ); +} + +// ── Props ──────────────────────────────────────────────────────────────────── +interface ArchivedCarTableProps { + cars: Car[]; + loading: boolean; + search: string; + page: number; + pages: number; + onView: (car: Car) => void; + onPageChange: (p: number) => void; +} + +// ── main table ─────────────────────────────────────────────────────────────── +export function ArchivedCarTable({ cars, loading, search, page, pages, onView, onPageChange }: ArchivedCarTableProps) { + return ( +
+ {/* column headers */} +
+ المركبة + رقم اللوحة + الفرع + آخر حالة + تاريخ الإضافة + إجراءات +
+ + {/* loading state */} + {loading ? ( +
+ + جارٍ التحميل… +
+ ) : cars.length === 0 ? ( + /* empty state */ +
+

+ {search ? `لا توجد نتائج لـ "${search}"` : "لا توجد مركبات في الأرشيف."} +

+
+ ) : ( + /* data rows */ +
    + {cars.map((c, i) => { + const status = STATUS_MAP[c.currentStatus]; + return ( +
  • +
    +

    + {c.manufacturer} {c.model} +

    +

    {c.year}{c.color ? ` · ${c.color}` : ""}

    +
    + + {c.plateLetters} {c.plateNumber} + + {c.branch?.name ?? "—"} + + + {status.label} + + + {fmtDateShort(c.createdAt)} + +
    + {/* view only — delete/restore not implemented yet */} + onView(c)}> + + + + + +
    +
  • + ); + })} +
+ )} + + {/* pagination */} + {pages > 1 && ( +
+ + صفحة {page} من {pages} + +
+ {[ + { label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 }, + { label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages }, + ].map(btn => ( + + ))} +
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/hooks/archive/Usearchivedcars.ts b/src/hooks/archive/Usearchivedcars.ts new file mode 100644 index 0000000..ace0811 --- /dev/null +++ b/src/hooks/archive/Usearchivedcars.ts @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { getStoredToken } from "@/src/lib/auth"; +import { carService } from "@/src/services/car.service"; +import type { Car } from "@/src/types/car"; + +const PAGE_SIZE = 12; + +// ── useArchivedCars ─────────────────────────────────────────────────────────── +// GET /cars/archived returns the full archived list at once (no page/search +// params on the backend), so pagination + search are done client-side here — +// mirrors the page/search/pages contract of useCars for a consistent UI. + +export function useArchivedCars() { + const [allCars, setAllCars] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + + const load = () => { + const token = getStoredToken(); + setLoading(true); + setError(null); + carService + .getArchived(token) + .then((res) => { + const payload = (res as unknown as { data: { data: Car[] } }).data ?? res; + setAllCars((payload as { data: Car[] }).data ?? []); + }) + .catch((err: Error) => setError(err.message)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { load(); }, []); + + const filtered = useMemo(() => { + if (!search.trim()) return allCars; + const q = search.trim().toLowerCase(); + return allCars.filter(c => + c.manufacturer.toLowerCase().includes(q) || + c.model.toLowerCase().includes(q) || + c.plateNumber.toLowerCase().includes(q) || + c.plateLetters.toLowerCase().includes(q), + ); + }, [allCars, search]); + + const total = filtered.length; + const pages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const cars = useMemo( + () => filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), + [filtered, page], + ); + + const handleSearch = (value: string) => { + setSearch(value); + setPage(1); + }; + + return { + cars, loading, error, total, pages, page, search, + setPage, handleSearch, refresh: load, setError, + }; +} \ No newline at end of file diff --git a/src/hooks/archive/useArchivedDrivers.ts b/src/hooks/archive/useArchivedDrivers.ts new file mode 100644 index 0000000..2ffe8aa --- /dev/null +++ b/src/hooks/archive/useArchivedDrivers.ts @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useState } from "react"; +import { getStoredToken } from "@/src/lib/auth"; +import { archivedDriverService } from "@/src/services/archive/archivedDriver.service"; +import type { ArchivedDriver } from "@/src/types/driver"; + +/** + * Loads and paginates the archived drivers list, mirroring useArchivedUsers. + * Re-fetches whenever `page` or `search` changes. + */ +export function useArchivedDrivers() { + const [drivers, setDrivers] = useState([]); + const [loading, setLoading] = useState(true); + const [total, setTotal] = useState(0); + const [pages, setPages] = useState(1); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [error, setError] = useState(null); + + /** Fetches the current page/search slice of archived drivers from the API. */ + const load = useCallback(async () => { + setLoading(true); + try { + const token = getStoredToken(); + const res = await archivedDriverService.getAll(page, search, token); + setDrivers(res.data.data); + setTotal(res.data.meta.total); + setPages(res.data.meta.totalPages); + setError(null); + } catch { + setError("تعذّر تحميل قائمة السائقين المؤرشفين. يرجى المحاولة لاحقاً."); + } finally { + setLoading(false); + } + }, [page, search]); + + useEffect(() => { load(); }, [load]); + + /** Updates the search term and resets to page 1. */ + const handleSearch = (value: string) => { + setSearch(value); + setPage(1); + }; + + return { + drivers, loading, total, pages, page, search, error, + setPage, handleSearch, clearError: () => setError(null), + refresh: load, + }; +} \ No newline at end of file diff --git a/src/hooks/archive/useArchivedOrders.ts b/src/hooks/archive/useArchivedOrders.ts new file mode 100644 index 0000000..357019c --- /dev/null +++ b/src/hooks/archive/useArchivedOrders.ts @@ -0,0 +1,58 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { getStoredToken } from "@/src/lib/auth"; +import { archivedOrderService } from "@/src/services/archive/archivedOrder.service"; +import type { ArchivedOrder } from "@/src/types/order"; + +const PAGE_SIZE = 10; + +export function useArchivedOrders() { + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const token = getStoredToken(); + const res = await archivedOrderService.getAll(token); + setOrders(res.data); + setError(null); + } catch { + setError("تعذّر تحميل الطلبات المؤرشفة. يرجى المحاولة لاحقاً."); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + // Client-side search — endpoint has no `?search=` param in the sample payload + const filtered = useMemo(() => { + if (!search.trim()) return orders; + const q = search.trim().toLowerCase(); + return orders.filter( + o => + o.shipmentNumber.toLowerCase().includes(q) || + o.recipientName.toLowerCase().includes(q) || + o.recipientPhone.includes(q), + ); + }, [orders, search]); + + const pages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE); + + const handleSearch = (value: string) => { + setSearch(value); + setPage(1); + }; + + return { + orders: paginated, + total: filtered.length, + loading, pages, page, search, error, + setPage, handleSearch, clearError: () => setError(null), + refresh: load, + }; +} \ No newline at end of file diff --git a/src/hooks/archive/useArchivedTrips.ts b/src/hooks/archive/useArchivedTrips.ts new file mode 100644 index 0000000..0320b16 --- /dev/null +++ b/src/hooks/archive/useArchivedTrips.ts @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useState } from "react"; +import { getStoredToken } from "@/src/lib/auth"; +import { archivedTripService } from "@/src/services/archive/archivedTrip.service"; +import type { Trip } from "@/src/types/trip"; + +export function useArchivedTrips() { + const [trips, setTrips] = useState([]); + const [loading, setLoading] = useState(true); + const [total, setTotal] = useState(0); + const [pages, setPages] = useState(1); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [error, setError] = useState(null); + + // ── Load archived trips for the current page/search ─────────────────────── + const load = useCallback(async () => { + setLoading(true); + try { + const token = getStoredToken(); + const res = await archivedTripService.getAll(page, search, token); + setTrips(res.data.data); + setTotal(res.data.meta.total); + setPages(res.data.meta.totalPages); + setError(null); + } catch { + // Network/API failure — surface a friendly Arabic message, matching useArchivedUsers + setError("تعذّر تحميل قائمة الرحلات المؤرشفة. يرجى المحاولة لاحقاً."); + } finally { + setLoading(false); + } + }, [page, search]); + + useEffect(() => { load(); }, [load]); + + const handleSearch = (value: string) => { + setSearch(value); + setPage(1); + }; + + return { + trips, loading, total, pages, page, search, error, + setPage, handleSearch, clearError: () => setError(null), + refresh: load, + }; +} \ No newline at end of file diff --git a/src/hooks/archive/useArchivedUsers.ts b/src/hooks/archive/useArchivedUsers.ts new file mode 100644 index 0000000..9dd8bce --- /dev/null +++ b/src/hooks/archive/useArchivedUsers.ts @@ -0,0 +1,43 @@ +import { useCallback, useEffect, useState } from "react"; +import { getStoredToken } from "@/src/lib/auth"; +import { archivedUserService } from "@/src/services/archive/archivedUser.service"; +import type { ArchivedUser } from "@/src/types/user"; + +export function useArchivedUsers() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [total, setTotal] = useState(0); + const [pages, setPages] = useState(1); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const token = getStoredToken(); + const res = await archivedUserService.getAll(page, search, token); + setUsers(res.data.data); + setTotal(res.data.meta.total); + setPages(res.data.meta.totalPages); + setError(null); + } catch { + setError("تعذّر تحميل قائمة الأرشيف. يرجى المحاولة لاحقاً."); + } finally { + setLoading(false); + } + }, [page, search]); + + useEffect(() => { load(); }, [load]); + + const handleSearch = (value: string) => { + setSearch(value); + setPage(1); + }; + + return { + users, loading, total, pages, page, search, error, + setPage, handleSearch, clearError: () => setError(null), + refresh: load, + }; +} \ No newline at end of file diff --git a/src/services/archive/archivedDriver.service.ts b/src/services/archive/archivedDriver.service.ts new file mode 100644 index 0000000..9e6840c --- /dev/null +++ b/src/services/archive/archivedDriver.service.ts @@ -0,0 +1,46 @@ +import { get } from "../api"; +import type { + ArchivedDriverListResponse, + ArchivedDriverResponse, + ArchivedDriverStatusHistoryResponse, +} from "@/src/types/driver"; + +/** + * Builds the query string for the archived drivers list endpoint. + * @param page - 1-indexed page number + * @param search - free-text search term (name or phone), omitted if empty + * @returns a query string starting with "?" + */ +function buildArchivedQuery(page: number, search: string): string { + return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`; +} + +export const archivedDriverService = { + /** + * Fetches the paginated list of archived drivers. + * @param page - page number to fetch + * @param search - optional search term + * @param token - auth token, or null if unauthenticated + */ + getAll: (page: number, search: string, token: string | null) => + get( + `driver/archived${buildArchivedQuery(page, search)}`, + token, + ), + + /** + * Fetches a single archived driver by id. + * @param id - archived driver id + * @param token - auth token, or null if unauthenticated + */ + getById: (id: string, token: string | null) => + get(`driver/archived/${id}`, token), + + /** + * Fetches the status history for an archived driver. + * @param id - archived driver id + * @param token - auth token, or null if unauthenticated + */ + getStatusHistory: (id: string, token: string | null) => + get(`driver/archived/driverStatus/${id}`, token), +}; \ No newline at end of file diff --git a/src/services/archive/archivedOrder.service.ts b/src/services/archive/archivedOrder.service.ts new file mode 100644 index 0000000..8043925 --- /dev/null +++ b/src/services/archive/archivedOrder.service.ts @@ -0,0 +1,8 @@ +import { get } from "../api"; +import type { ArchivedOrderListResponse } from "@/src/types/order"; + +export const archivedOrderService = { + /** Fetching the full archived order list (endpoint returns no pagination meta) */ + getAll: (token: string | null) => + get("orders/archived", token), +}; \ No newline at end of file diff --git a/src/services/archive/archivedTrip.service.ts b/src/services/archive/archivedTrip.service.ts new file mode 100644 index 0000000..c8a36d2 --- /dev/null +++ b/src/services/archive/archivedTrip.service.ts @@ -0,0 +1,20 @@ +import { get } from "../api"; +import type { ArchivedTripListResponse, ArchivedTripResponse } from "@/src/types/trip"; + +/** Building a query string to fetch archived trips with pagination */ +function buildArchivedQuery(page: number, search: string): string { + return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`; +} + +export const archivedTripService = { + /** Fetching the archived trip list, paginated */ + getAll: (page: number, search: string, token: string | null) => + get( + `trip/archived${buildArchivedQuery(page, search)}`, + token, + ), + + /** Get a single archived trip by id */ + getById: (id: string, token: string | null) => + get(`trip/archived/${id}`, token), +}; \ No newline at end of file diff --git a/src/services/archive/archivedUser.service.ts b/src/services/archive/archivedUser.service.ts new file mode 100644 index 0000000..387ecb4 --- /dev/null +++ b/src/services/archive/archivedUser.service.ts @@ -0,0 +1,25 @@ +import { get } from "../api"; +import type { + ArchivedUserListResponse, + ArchivedUserResponse, +} from "@/src/types/user"; + +/** Building a query string to fetch archived users with pagination */ +function buildArchivedQuery(page: number, search: string): string { + return `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`; +} + +export const archivedUserService = { + /** Fetching the archived user list, paginated */ + getAll: (page: number, search: string, token: string | null) => + get( + `users/archived${buildArchivedQuery(page, search)}`, + token, + ), + + /** Get a single archived user by id */ + getById: (id: string, token: string | null) => + get(`users/archived/${id}`, token), + + // NOTE: delete/restore intentionally left out for now — see ticket follow-up. +}; \ No newline at end of file diff --git a/src/types/driver.ts b/src/types/driver.ts index c0fe1a3..421aba9 100644 --- a/src/types/driver.ts +++ b/src/types/driver.ts @@ -126,3 +126,61 @@ export const DRIVER_CARD_TYPE_MAP: Record = { Restricted: "مقيدة", }; +// ── Archived Driver ────────────────────────────────────────────────────────── +// Resource returned by the /driver/archived endpoints. Kept as a distinct +// shape (rather than reusing Driver) since the archive endpoints return a +// flatter, slightly different set of fields — mirrors the ArchivedUser +// pattern in src/types/user.ts. + +export interface ArchivedDriver { + id: string; + name: string; + userName?: string | null; + email?: string | null; + phone: string; + address?: string | null; + nationality?: string | null; + nationalId?: string | null; + nationalIdType?: NationalIdType | null; + gosiNumber?: string | null; + licenseNumber?: string | null; + licenseType?: string | null; + licenseExpiry?: string | null; + photo?: string | null; + photoUrl?: string | null; + nationalPhoto?: string | null; + nationalPhotoUrl?: string | null; + driverCardPhoto?: string | null; + driverCardPhotoUrl?: string | null; + driverCardNumber?: string | null; + driverCardType?: DriverCardType | null; + status: DriverStatus; + isActive: boolean; + isDeleted: boolean; + driverType?: string | null; + createdAt: string; + updatedAt: string; +} + +// GET /driver/archived +export interface ArchivedDriverListResponse { + data: { + data: ArchivedDriver[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; + }; +} + +// GET /driver/archived/{id} +export interface ArchivedDriverResponse { + data: ArchivedDriver; +} + +// GET /driver/archived/driverStatus/{id} +export interface ArchivedDriverStatusHistoryResponse { + data: DriverStatusHistoryEntry[]; +} \ No newline at end of file diff --git a/src/types/order.ts b/src/types/order.ts index ab727a3..4ce3975 100644 --- a/src/types/order.ts +++ b/src/types/order.ts @@ -106,4 +106,34 @@ export interface OrdersResponse { limit: number; totalPages: number; }; +} +// Archived order resource returned by /orders/archived. +// NOTE: sample payload has no pagination meta — adjust if backend confirms otherwise. +export interface ArchivedOrder { + id: string; + shipmentNumber: string; + recipientName: string; + recipientPhone: string; + clientId: string; + tripId?: string | null; + pickupAddressId?: string | null; + deliveryAddressId?: string | null; + currentStatus: OrderStatus; + quantity: number; + subTotal?: number | null; + createdAt: string; + updatedAt: string; + statusHistory?: Array<{ + id: string; + status: OrderStatus; + reason?: string | null; + createdAt: string; + }>; +} + +export interface ArchivedOrderListResponse { + success: boolean; + message: string; + responseAt: string; + data: ArchivedOrder[]; } \ No newline at end of file diff --git a/src/types/trip.ts b/src/types/trip.ts index d963be9..8719798 100644 --- a/src/types/trip.ts +++ b/src/types/trip.ts @@ -155,3 +155,21 @@ export const TRIP_STATUS_MAP: Record< Cancelled: { label: "ملغاة", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA", dot: "#DC2626" }, }; +// ── Archived Trip types (mirrors ArchivedUser pattern in types/user.ts) ───── +// GET /trip/archived/{id} +export interface ArchivedTripResponse { + data: Trip; +} + +// GET /trip/archived — paginated list +export interface ArchivedTripListResponse { + data: { + data: Trip[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; + }; +} \ No newline at end of file diff --git a/src/types/user.ts b/src/types/user.ts index 5c6f4c4..7479c3d 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -65,4 +65,50 @@ export type TableAction = passwordChangedAt: string | null; role: { id?: string; name: string; description?: string }; branch: { id?: string; name: string }; +} + +// Archived user resource returned by the archive endpoints +export interface ArchivedUser { + id: string; + name: string; + userName?: string; + email?: string; + phone: string; + photo: string | null; + isActive: boolean; + isDeleted: boolean; + createdAt: string; + updatedAt: string; +} + +// GET /v1/users/archived/{id} +export interface ArchivedUserResponse { + data: ArchivedUser; +} + +// GET /v1/users/archived (note: this endpoint uses `meta`, not `pagination`, +// unlike the live users list — kept as a distinct shape rather than reusing +// ApiListResponse from user.ts) +export interface ArchivedUserListResponse { + data: { + data: ArchivedUser[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; + }; +} + +// Shared API error shape (validation_failed etc.) +export interface ApiErrorResponse { + success: false; + message: string; + responseAt: string; + error: { + code: string; + path: string; + details: { field: string; code: string }[]; + }; } \ No newline at end of file