add archive layout pages to user car driver trip order

This commit is contained in:
m7amedez5511
2026-06-30 17:35:18 +03:00
parent f5882f3791
commit c33778012a
33 changed files with 2669 additions and 30 deletions

View File

@@ -1,11 +1,12 @@
"use client"; "use client";
// app/(dashboard)/cars/page.tsx
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Spinner, Alert } from "@/src/Components/UI"; import { Spinner, Alert, ArchiveButton } from "@/src/Components/UI";
import { CarFormModal } from "@/src/Components/car/CarFormModal"; import { CarFormModal } from "@/src/Components/car/CarFormModal";
import { CarDetailPanel } from "@/src/Components/car/CarDetailPanel"; import { CarDetailPanel } from "@/src/Components/car/CarDetailPanel";
import { CarDeleteModal } from "@/src/Components/car/CarDeleteModal"; import { CarDeleteModal } from "@/src/Components/car/CarDeleteModal";
import { ArchivedCarsModal } from "@/src/Components/car/archive/Archivedcarsmodal";
import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars"; import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars";
import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/src/types/car"; import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/src/types/car";
import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } 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<string | null>(null); const [detailId, setDetailId] = useState<string | null>(null);
const [formTarget, setFormTarget] = useState<Car | null | false>(false); // false = closed const [formTarget, setFormTarget] = useState<Car | null | false>(false); // false = closed
const [deleteTarget, setDeleteTarget] = useState<Car | null>(null); const [deleteTarget, setDeleteTarget] = useState<Car | null>(null);
const [archiveOpen, setArchiveOpen] = useState(false);
const { toast, notify } = useToast(); const { toast, notify } = useToast();
@@ -226,6 +228,10 @@ export default function CarsPage() {
/> />
)} )}
{archiveOpen && (
<ArchivedCarsModal onClose={() => setArchiveOpen(false)} />
)}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }} dir="rtl"> <section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }} dir="rtl">
{/* ── Header ── */} {/* ── Header ── */}
@@ -400,6 +406,9 @@ export default function CarsPage() {
</div> </div>
)} )}
</section> </section>
{/* Floating button to open the archive browser */}
<ArchiveButton onClick={() => setArchiveOpen(true)} />
</> </>
); );
} }

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { useState, useCallback } from "react"; 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 { DriverFormModal } from "@/src/Components/Driver/DriverFormModal";
import { DriverDeleteModal } from "@/src/Components/Driver/DriverDeleteModal"; import { DriverDeleteModal } from "@/src/Components/Driver/DriverDeleteModal";
import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers";
import { useDrivers } from "@/src/hooks/useDriver"; import { useDrivers } from "@/src/hooks/useDriver";
import { CreateDriverPayload, Driver, DRIVER_STATUS_MAP, UpdateDriverPayload } from "@/src/types/driver"; import { CreateDriverPayload, Driver, DRIVER_STATUS_MAP, UpdateDriverPayload } from "@/src/types/driver";
import { DriverDetailPanel } from "@/src/Components/Driver/DriverDetailPanel"; import { DriverDetailPanel } from "@/src/Components/Driver/DriverDetailPanel";
@@ -74,6 +75,8 @@ export default function DriversPage() {
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
// Bumped after a successful edit to force the detail panel to re-fetch // Bumped after a successful edit to force the detail panel to re-fetch
const [panelRefreshKey, setPanelRefreshKey] = useState(0); const [panelRefreshKey, setPanelRefreshKey] = useState(0);
// Archive browser modal open/closed
const [archiveOpen, setArchiveOpen] = useState(false);
// ── Handlers ────────────────────────────────────────────────────────────── // ── Handlers ──────────────────────────────────────────────────────────────
const handleEdit = useCallback((driver: Driver) => { const handleEdit = useCallback((driver: Driver) => {
@@ -418,6 +421,14 @@ export default function DriversPage() {
onConfirm={handleConfirmDelete} onConfirm={handleConfirmDelete}
/> />
)} )}
{/* ── Archive browser modal ── */}
{archiveOpen && (
<ArchivedDrivers onClose={() => setArchiveOpen(false)} />
)}
{/* ── Floating button to open the archive browser ── */}
<ArchiveButton onClick={() => setArchiveOpen(true)} />
</> </>
); );
} }

View File

@@ -3,10 +3,11 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { Alert, Spinner, Toast } from "@/src/Components/UI"; import { Alert, Spinner, Toast, ArchiveButton } from "@/src/Components/UI";
import { OrderFormModal } from "@/src/Components/Order/Orderformmodal"; import { OrderFormModal } from "@/src/Components/Order/OrderFormModal";
import { OrderDeleteModal } from "@/src/Components/Order/Orderdeletemodal"; import { OrderDeleteModal } from "@/src/Components/Order/OrderDeleteModal";
import { OrderDetailPanel, ORDER_STATUS_MAP } from "@/src/Components/Order/OrderDetailBanel"; 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 { useOrders } from "@/src/hooks/useOrder";
import type { CreateOrderPayload, Order, UpdateOrderPayload } from "@/src/types/order"; 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 // Bumped after a successful edit/status-change to force the detail panel
// to re-fetch — same trick as DriversPage's panelRefreshKey. // to re-fetch — same trick as DriversPage's panelRefreshKey.
const [panelRefreshKey, setPanelRefreshKey] = useState(0); const [panelRefreshKey, setPanelRefreshKey] = useState(0);
// Archive browser modal open/closed
const [archiveOpen, setArchiveOpen] = useState(false);
// ── Handlers ───────────────────────────────────────────────────────── // ── Handlers ─────────────────────────────────────────────────────────
const handleEdit = useCallback((order: Order) => { const handleEdit = useCallback((order: Order) => {
@@ -447,12 +450,20 @@ export default function OrderComponent() {
/> />
)} )}
{/* ── Archive browser modal ── */}
{archiveOpen && (
<ArchivedOrdersModal onClose={() => setArchiveOpen(false)} />
)}
{/* ── Toast: transient success/error feedback for create, update, {/* ── Toast: transient success/error feedback for create, update,
delete, and status-change actions — sourced from useOrders()' delete, and status-change actions — sourced from useOrders()'
`notification` state (see useOrders.ts notify()). This is the `notification` state (see useOrders.ts notify()). This is the
same toast used by the Driver pages via DriverDeleteModal's same toast used by the Driver pages via DriverDeleteModal's
parent and useDriver.ts, kept consistent here. ── */} parent and useDriver.ts, kept consistent here. ── */}
<Toast notification={notification} /> <Toast notification={notification} />
{/* Floating button to open the archive browser */}
<ArchiveButton onClick={() => setArchiveOpen(true)} />
</> </>
); );
} }

View File

@@ -7,7 +7,8 @@ import { tripService } from "@/src/services/trip.service";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { TripFormModal } from "@/src/Components/Trip/Tripformmodal"; import { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
import { TripDeleteModal } from "@/src/Components/Trip/Tripdeletemodal"; 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 { Toast, type ToastNotification } from "@/src/Components/UI/Toast";
import type { Trip, TripStatus, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip"; import type { Trip, TripStatus, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip";
import { TRIP_STATUS_MAP } 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 <ArchivedTripList /> — 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 (
<div
role="dialog" aria-modal="true" aria-labelledby="archived-trips-title"
onClick={e => { 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",
}}
>
<div
onClick={e => 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 */}
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<h2 id="archived-trips-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
أرشيف الرحلات
</h2>
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
{/* body */}
<div style={{ padding: "1.5rem" }}>
<ArchivedTripList onView={trip => router.push(`/dashboard/trips/${trip.id}`)} />
</div>
</div>
</div>
);
}
// ── Page ───────────────────────────────────────────────────────────────────── // ── Page ─────────────────────────────────────────────────────────────────────
export default function TripsPage() { export default function TripsPage() {
@@ -93,6 +155,8 @@ export default function TripsPage() {
const [editTrip, setEditTrip] = useState<Trip | null>(null); const [editTrip, setEditTrip] = useState<Trip | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Trip | null>(null); const [deleteTarget, setDeleteTarget] = useState<Trip | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
// Archive browser modal open/closed
const [archiveOpen, setArchiveOpen] = useState(false);
// ── Create / update — calls tripService directly and notifies explicitly ── // ── Create / update — calls tripService directly and notifies explicitly ──
const handleSubmit = async ( const handleSubmit = async (
@@ -146,6 +210,25 @@ export default function TripsPage() {
إدارة جدولة الرحلات وتتبع حالتها إدارة جدولة الرحلات وتتبع حالتها
</p> </p>
</div> </div>
<div style={{ display: "flex", gap: "0.75rem" }}>
<button
onClick={() => setArchiveOpen(true)}
style={{
height: 40, padding: "0 1.25rem",
borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", color: "var(--color-text-secondary)",
fontSize: 13, fontWeight: 700, cursor: "pointer",
display: "flex", alignItems: "center", gap: 8,
fontFamily: "var(--font-sans)",
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="4" width="20" height="5" rx="1" />
<path d="M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9" />
<path d="M10 13h4" />
</svg>
أرشيف الرحلات
</button>
<button <button
onClick={() => { setEditTrip(null); setShowForm(true); }} onClick={() => { setEditTrip(null); setShowForm(true); }}
style={{ style={{
@@ -163,6 +246,7 @@ export default function TripsPage() {
إضافة رحلة إضافة رحلة
</button> </button>
</div> </div>
</div>
{/* ── Filters ── */} {/* ── Filters ── */}
<div style={{ <div style={{
@@ -367,6 +451,9 @@ export default function TripsPage() {
onConfirm={handleDelete} onConfirm={handleDelete}
/> />
)} )}
{archiveOpen && (
<ArchivedTripsModal onClose={() => setArchiveOpen(false)} />
)}
{/* ── Toast ── */} {/* ── Toast ── */}
<Toast notification={notification} onDismiss={dismissNotification} /> <Toast notification={notification} onDismiss={dismissNotification} />

View File

@@ -1,13 +1,14 @@
"use client"; "use client";
import { useState } from "react"; 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 { UserTable } from "@/src/Components/User/UserTable";
import { UserFormModal } from "@/src/Components/User/UserFormModal"; import { UserFormModal } from "@/src/Components/User/UserFormModal";
import { UserDetailModal } from "@/src/Components/User/UserDetailModal"; import { UserDetailModal } from "@/src/Components/User/UserDetailModal";
import { DeleteConfirmModal } from "@/src/Components/User/DeleteConfirmModal"; import { DeleteConfirmModal } from "@/src/Components/User/DeleteConfirmModal";
import { useUsers } from "@/src/hooks/useUser"; import { useUsers } from "@/src/hooks/useUser";
import type { User, UserFormData } from "@/src/types/user"; import type { User, UserFormData } from "@/src/types/user";
import { ArchivedUsersModal } from "@/src/Components/User/archive/Archivedusersmodal";
export default function UsersPage() { export default function UsersPage() {
// ── Modal state ───────────────────────────────────────────────────────────── // ── Modal state ─────────────────────────────────────────────────────────────
@@ -16,6 +17,8 @@ export default function UsersPage() {
const [deleteTarget, setDeleteTarget] = useState<User | null>(null); const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
// ID of user whose detail modal is open; null = closed // ID of user whose detail modal is open; null = closed
const [viewUserId, setViewUserId] = useState<string | null>(null); const [viewUserId, setViewUserId] = useState<string | null>(null);
// Archive browser modal open/closed
const [archiveOpen, setArchiveOpen] = useState(false);
// Local submitting flag shown in DeleteConfirmModal spinner // Local submitting flag shown in DeleteConfirmModal spinner
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@@ -84,6 +87,11 @@ export default function UsersPage() {
/> />
)} )}
{/* Archive browser modal */}
{archiveOpen && (
<ArchivedUsersModal onClose={() => setArchiveOpen(false)} />
)}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}> <section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Page header ── */} {/* ── Page header ── */}
@@ -178,6 +186,9 @@ export default function UsersPage() {
onPageChange={setPage} onPageChange={setPage}
/> />
</section> </section>
{/* Floating button to open the archive browser */}
<ArchiveButton onClick={() => setArchiveOpen(true)} />
</> </>
); );
} }

View File

@@ -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 (
<div style={{
display: "flex", flexDirection: "column", gap: 4,
padding: "0.75rem 0",
borderBottom: "1px solid var(--color-border)",
}}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
{label}
</span>
<span style={{ fontSize: 13, fontWeight: 500, color: value ? "var(--color-text-primary)" : "var(--color-text-hint)" }}>
{value || "—"}
</span>
</div>
);
}
function Avatar({ name }: { name: string }) {
const initials = name.trim().split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase();
return (
<div style={{
width: 64, height: 64, borderRadius: "50%",
background: "linear-gradient(135deg, #EA580C 0%, #B91C1C 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 22, fontWeight: 700, color: "#FFF",
flexShrink: 0,
boxShadow: "0 4px 12px rgba(234,88,12,.3)",
}}>
{initials}
</div>
);
}
/**
* 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<ArchivedDriver | null>(null);
const [history, setHistory] = useState<DriverStatusHistoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div
role="dialog" aria-modal="true" aria-labelledby="archived-driver-detail-title"
onClick={e => { 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",
}}
>
<div
onClick={e => 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 ── */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
سائق مؤرشف
</p>
<h2 id="archived-driver-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{driver?.name ?? "عرض السائق"}
</h2>
</div>
<button
type="button" onClick={onClose} aria-label="إغلاق"
style={{
width: 34, height: 34, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
cursor: "pointer", fontSize: 18,
color: "var(--color-text-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
×
</button>
</div>
{/* ── body ── */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "75vh" }}>
{/* loading */}
{loading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{/* error */}
{!loading && error && (
<div style={{
padding: "1rem 1.25rem",
borderRadius: "var(--radius-lg)",
background: "#FEF2F2", border: "1px solid #FECACA",
fontSize: 13, color: "#991B1B", fontWeight: 500,
textAlign: "center",
}}>
{error}
</div>
)}
{/* content */}
{!loading && driver && (
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
{/* avatar + name + status row */}
<div style={{
display: "flex", alignItems: "center", gap: "1rem",
padding: "0 0 1.25rem",
borderBottom: "1px solid var(--color-border)",
marginBottom: "0.25rem",
}}>
<Avatar name={driver.name} />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{driver.name}</p>
{driver.userName && (
<p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
@{driver.userName}
</p>
)}
{statusConfig && (
<span style={{
marginTop: 8, display: "inline-flex", alignItems: "center", gap: 6,
borderRadius: "var(--radius-full)",
border: `1px solid ${statusConfig.border}`,
background: statusConfig.bg,
padding: "0.25rem 0.75rem",
fontSize: 12, fontWeight: 600, color: statusConfig.color,
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot }} />
{statusConfig.label}
</span>
)}
</div>
</div>
{/* core info */}
<DetailRow label="رقم الجوال" value={driver.phone} />
<DetailRow label="البريد الإلكتروني" value={driver.email} />
<DetailRow label="العنوان" value={driver.address} />
<DetailRow label="الجنسية" value={driver.nationality} />
<DetailRow label="نوع الهوية" value={driver.nationalIdType ? NATIONAL_ID_TYPE_MAP[driver.nationalIdType] : null} />
<DetailRow label="رقم الهوية" value={driver.nationalId} />
<DetailRow label="رقم GOSI" value={driver.gosiNumber} />
<DetailRow label="رقم الرخصة" value={driver.licenseNumber} />
<DetailRow label="نوع الرخصة" value={driver.licenseType} />
<DetailRow label="انتهاء الرخصة" value={fmt(driver.licenseExpiry)} />
<DetailRow label="رقم بطاقة السائق" value={driver.driverCardNumber} />
<DetailRow label="نوع بطاقة السائق" value={driver.driverCardType ? DRIVER_CARD_TYPE_MAP[driver.driverCardType] : null} />
<DetailRow label="نوع السائق" value={driver.driverType} />
<DetailRow label="تاريخ الإنشاء" value={fmt(driver.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(driver.updatedAt)} />
{/* photos */}
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "1.25rem 0 0.5rem" }}>
الصور والمستندات
</p>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "0.75rem" }}>
<PhotoCard url={driver.photoUrl} label="صورة السائق" />
<PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" />
<PhotoCard url={driver.driverCardPhotoUrl} label="صورة البطاقة" />
</div>
{/* status history */}
{history.length > 0 && (
<>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "1.25rem 0 0.5rem" }}>
سجل الحالات
</p>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{history.map((h) => {
const s = DRIVER_STATUS_MAP[h.status] ?? DRIVER_STATUS_MAP.Active;
return (
<div key={h.id} style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
borderRadius: "var(--radius-md)", border: `1px solid ${s.border}`,
background: s.bg, padding: "0.5rem 0.875rem",
}}>
<div>
<span style={{ fontSize: 12, fontWeight: 600, color: s.color }}>{s.label}</span>
{h.reason && (
<span style={{ fontSize: 11, color: "var(--color-text-muted)", marginRight: 8 }}>
{h.reason}
</span>
)}
</div>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{fmt(h.createdAt)}</span>
</div>
);
})}
</div>
</>
)}
</div>
)}
</div>
{/* ── footer ── */}
<div style={{
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", justifyContent: "flex-end",
}}>
<button
type="button" onClick={onClose}
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
إغلاق
</button>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${cfg.border}`, background: cfg.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: cfg.color }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: cfg.dot }} />
{cfg.label}
</span>
);
}
// ── icon button ──────────────────────────────────────────────────────────────
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
// ── 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 (
<div style={cardStyle}>
{/* column headers */}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS, ...thStyle }}>
<span>الاسم</span>
<span>اسم المستخدم</span>
<span>الحالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>آخر تحديث</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{/* loading state */}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : drivers.length === 0 ? (
/* empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد سائقون في الأرشيف."}
</p>
</div>
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{drivers.map((d, i) => (
<li key={d.id} onClick={() => 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",
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p>
</div>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{d.userName ?? "—"}</span>
<StatusBadge status={d.status} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(d.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(d.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view only — delete/restore not implemented yet, same as users */}
<IconBtn title={`عرض ${d.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(d)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)}
{/* pagination */}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
].map(btn => (
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -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<string | null>(null);
const {
drivers, loading, total, pages, error,
page, search,
setPage, handleSearch, clearError,
} = useArchivedDrivers();
return (
<div
role="dialog" aria-modal="true" aria-labelledby="archive-driver-modal-title"
onClick={e => { 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 && (
<ArchivedDriverDetailModal driverId={viewDriverId} onClose={() => setViewDriverId(null)} />
)}
<div
onClick={e => 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 */}
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="archive-driver-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
السائقون المؤرشفون
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</h2>
</div>
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
{/* body */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث بالاسم أو الهاتف..."
value={search}
onChange={e => 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)",
}}
/>
</div>
{error && <Alert type="error" message={error} onClose={clearError} />}
<ArchivedDriverTable
drivers={drivers}
loading={loading}
search={search}
page={page}
pages={pages}
onView={driver => setViewDriverId(driver.id)}
onPageChange={setPage}
/>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", padding: "0.55rem 0", borderBottom: "1px solid var(--color-border)" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>{label}</span>
<span style={{ fontSize: 13, fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)", color: "var(--color-text-primary)" }}>{value}</span>
</div>
);
}
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 (
<div role="dialog" aria-modal="true" aria-labelledby="archived-order-title"
onClick={e => { 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" }}>
<div onClick={e => 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" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "1.25rem 1.5rem", borderBottom: "1px solid var(--color-border)", background: "var(--color-surface-muted)" }}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>طلب مؤرشف</p>
<h2 id="archived-order-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0", fontFamily: "var(--font-mono)" }}>
{order.shipmentNumber}
</h2>
</div>
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }} dir="rtl">
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: s.color, marginBottom: 12 }}>
<span style={{ width: 7, height: 7, borderRadius: "50%", background: s.dot }} />
{s.label}
</span>
<DetailRow label="اسم المستلم" value={order.recipientName} />
<DetailRow label="رقم الجوال" value={order.recipientPhone} mono />
<DetailRow label="الكمية" value={String(order.quantity)} />
<DetailRow label="الإجمالي الفرعي" value={order.subTotal != null ? `${order.subTotal.toFixed(2)} ر.س` : "—"} mono />
<DetailRow label="تاريخ الإنشاء" value={fmtDate(order.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(order.updatedAt)} />
{order.statusHistory && order.statusHistory.length > 0 && (
<div style={{ marginTop: 16, display: "flex", flexDirection: "column", gap: 6 }}>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "0 0 4px" }}>سجل الحالات</p>
{order.statusHistory.map(h => {
const hs = ORDER_STATUS_MAP[h.status] ?? ORDER_STATUS_MAP.Created;
return (
<div key={h.id} style={{ display: "flex", justifyContent: "space-between", alignItems: "center", borderRadius: "var(--radius-md)", border: `1px solid ${hs.border}`, background: hs.bg, padding: "0.5rem 0.875rem" }}>
<span style={{ fontSize: 12, fontWeight: 600, color: hs.color }}>{hs.label}{h.reason ? `${h.reason}` : ""}</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{fmtDate(h.createdAt)}</span>
</div>
);
})}
</div>
)}
</div>
<div style={{ padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)", background: "var(--color-surface-muted)", display: "flex", justifyContent: "flex-end" }}>
<button type="button" onClick={onClose}
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
إغلاق
</button>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div style={cardStyle}>
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: ROW_GRID, ...thStyle }}>
<span>رقم الشحنة</span>
<span>المستلم</span>
<span>المبلغ</span>
<span>الحالة</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : orders.length === 0 ? (
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد طلبات في الأرشيف."}
</p>
</div>
) : (
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{orders.map((o, i) => {
const s = ORDER_STATUS_MAP[o.currentStatus] ?? ORDER_STATUS_MAP.Created;
return (
<li key={o.id} style={{
display: "grid", gridTemplateColumns: ROW_GRID,
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,
}}>
<span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "#2563EB" }}>
{o.shipmentNumber}
</span>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{o.recipientPhone}</p>
</div>
<span style={{ fontWeight: 600, fontFamily: "var(--font-mono)", color: "var(--color-text-primary)" }}>
{fmtAmount(o.subTotal)}
</span>
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`,
background: s.bg, padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 600, color: s.color, width: "fit-content",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
{s.label}
</span>
<div style={{ display: "flex", justifyContent: "center" }}>
<button type="button" title={`عرض ${o.shipmentNumber}`} aria-label={`عرض ${o.shipmentNumber}`}
onClick={e => { e.stopPropagation(); onView(o); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: "1px solid #A7F3D0", background: "#ECFDF5", color: "#059669", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
</li>
);
})}
</ul>
)}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
].map(btn => (
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -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<ArchivedOrder | null>(null);
const {
orders, loading, total, pages, error,
page, search,
setPage, handleSearch, clearError,
} = useArchivedOrders();
return (
<div
role="dialog" aria-modal="true" aria-labelledby="archive-orders-modal-title"
onClick={e => { 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 && (
<ArchivedOrderDetailModal order={viewOrder} onClose={() => setViewOrder(null)} />
)}
<div
onClick={e => 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 */}
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="archive-orders-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
الطلبات المؤرشفة
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</h2>
</div>
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
{/* body */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="رقم الشحنة أو المستلم..."
value={search}
onChange={e => 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)",
}}
/>
</div>
{error && <Alert type="error" message={error} onClose={clearError} />}
<ArchivedOrderTable
orders={orders}
loading={loading}
search={search}
page={page}
pages={pages}
onView={order => setViewOrder(order)}
onPageChange={setPage}
/>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<span
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[11px] font-bold"
style={{ color: s.color, background: s.bg, border: `1px solid ${s.border}` }}
>
<span className="h-1.5 w-1.5 rounded-full" style={{ background: s.dot }} />
{s.label}
</span>
);
}
// ── 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
* <ArchivedTripList onView={(trip) => setViewTripId(trip.id)} />
*/
export function ArchivedTripList({ onView }: ArchivedTripListProps) {
const {
trips, loading, total, pages, page, search, error,
setPage, handleSearch, clearError,
} = useArchivedTrips();
return (
<div dir="rtl" className="flex flex-col gap-4">
{/* ── Header: count + search ── */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 className="text-base font-bold text-[var(--color-text-primary)]">
الرحلات المؤرشفة
<span className="mr-2 text-[13px] font-medium text-[var(--color-text-muted)]">
({total})
</span>
</h2>
<div className="relative w-full sm:w-72">
<svg
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--color-text-hint)]"
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24"
>
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث برقم الرحلة أو العنوان..."
value={search}
onChange={(e) => 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"
/>
</div>
</div>
{/* ── API error feedback ── */}
{error && <Alert type="error" message={error} onClose={clearError} />}
{/* ── Card ── */}
<div className="overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)]">
{/* column headers */}
<div className="grid grid-cols-[2fr_1.5fr_1fr_1fr_1fr] gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-muted)] px-6 py-3 text-[11px] font-bold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
<span>الرحلة</span>
<span>الحالة</span>
<span className="text-center">البدء</span>
<span className="text-center">الانتهاء</span>
<span className="text-center">النقد المحصّل</span>
</div>
{/* ── Loading state ── */}
{loading ? (
<div className="flex items-center justify-center gap-3 py-16 text-[var(--color-text-muted)]">
<Spinner size="sm" />
<span className="text-[13px]">جارٍ التحميل</span>
</div>
) : trips.length === 0 ? (
/* ── Empty state ── */
<EmptyState
icon="🗄️"
title="لا توجد رحلات مؤرشفة"
description={search ? `لا توجد نتائج لـ "${search}"` : "لم يتم أرشفة أي رحلات بعد."}
/>
) : (
/* ── Data rows ── */
<ul className="m-0 list-none p-0">
{trips.map((trip, i) => (
<li
key={trip.id}
onClick={() => 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" : ""}`}
>
<div className="min-w-0">
<p className="m-0 truncate font-semibold text-[var(--color-text-primary)]">{trip.title}</p>
<p className="mt-0.5 font-mono text-[11px] text-[var(--color-brand-600)]">{trip.tripNumber}</p>
</div>
<div>
<TripStatusBadge status={trip.status} />
</div>
<span className="text-center text-[12px] text-[var(--color-text-muted)]">
{fmtDate(trip.startTime)}
</span>
<span className="text-center text-[12px] text-[var(--color-text-muted)]">
{fmtDate(trip.endTime)}
</span>
<span className="text-center font-mono text-[12px] font-semibold text-[var(--color-text-primary)]">
{trip.totalCashCollected != null
? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س`
: "—"}
</span>
</li>
))}
</ul>
)}
{/* ── Pagination ── */}
{pages > 1 && (
<div className="flex items-center justify-between border-t border-[var(--color-border)] px-6 py-3.5">
<span className="text-[12px] text-[var(--color-text-muted)]">
صفحة <strong className="text-[var(--color-text-primary)]">{page}</strong> من{" "}
<strong className="text-[var(--color-text-primary)]">{pages}</strong>
</span>
<div className="flex gap-2">
<button
type="button"
disabled={page === 1}
onClick={() => setPage(Math.max(1, page - 1))}
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3.5 py-1.5 text-[12px] text-[var(--color-text-secondary)] disabled:cursor-not-allowed disabled:opacity-40"
>
السابق
</button>
<button
type="button"
disabled={page === pages}
onClick={() => setPage(Math.min(pages, page + 1))}
className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-muted)] px-3.5 py-1.5 text-[12px] text-[var(--color-text-secondary)] disabled:cursor-not-allowed disabled:opacity-40"
>
التالي
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -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 (
<button
type="button"
onClick={onClick}
title={label}
aria-label={label}
style={{ ...base, ...floatingStyle }}
onMouseEnter={e => (e.currentTarget.style.transform = "translateY(-2px)")}
onMouseLeave={e => (e.currentTarget.style.transform = "translateY(0)")}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="4" width="20" height="5" rx="1" />
<path d="M4 9v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9" />
<path d="M10 13h4" />
</svg>
{label}
</button>
);
}

View File

@@ -22,3 +22,5 @@ export { ConfirmDialog } from "./ConfirmDialog";
export { PageHeader } from "./PageHeader"; export { PageHeader } from "./PageHeader";
export { EmptyState } from "./EmptyState"; export { EmptyState } from "./EmptyState";
export { Badge } from "./Badge"; export { Badge } from "./Badge";
export { ArchiveButton } from "./ArchiveButton"

View File

@@ -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 (
<div style={{
display: "flex", flexDirection: "column", gap: 4,
padding: "0.75rem 0",
borderBottom: "1px solid var(--color-border)",
}}>
<span style={{ fontSize: 11, fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.15em", color: "var(--color-text-muted)" }}>
{label}
</span>
<span style={{ fontSize: 13, fontWeight: 500, color: value ? "var(--color-text-primary)" : "var(--color-text-hint)" }}>
{value || "—"}
</span>
</div>
);
}
function StatusBadge({ active }: { active: boolean }) {
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 6,
borderRadius: "var(--radius-full)",
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
background: active ? "#DCFCE7" : "#FEF2F2",
padding: "0.25rem 0.75rem",
fontSize: 12, fontWeight: 600,
color: active ? "#166534" : "#991B1B",
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
function Avatar({ name }: { name: string }) {
const initials = name.trim().split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase();
return (
<div style={{
width: 64, height: 64, borderRadius: "50%",
background: "linear-gradient(135deg, #EA580C 0%, #B91C1C 100%)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 22, fontWeight: 700, color: "#FFF",
flexShrink: 0,
boxShadow: "0 4px 12px rgba(234,88,12,.3)",
}}>
{initials}
</div>
);
}
// ── main component ────────────────────────────────────────────────────────────
export function ArchivedUserDetailModal({ userId, onClose }: ArchivedUserDetailModalProps) {
const [user, setUser] = useState<ArchivedUser | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div
role="dialog" aria-modal="true" aria-labelledby="archived-detail-title"
onClick={e => { 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",
}}
>
<div
onClick={e => 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 ── */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
مستخدم مؤرشف
</p>
<h2 id="archived-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{user?.name ?? "عرض المستخدم"}
</h2>
</div>
<button
type="button" onClick={onClose} aria-label="إغلاق"
style={{
width: 34, height: 34, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
cursor: "pointer", fontSize: 18,
color: "var(--color-text-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
}}
>
×
</button>
</div>
{/* ── body ── */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }}>
{/* loading */}
{loading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{/* error */}
{!loading && error && (
<div style={{
padding: "1rem 1.25rem",
borderRadius: "var(--radius-lg)",
background: "#FEF2F2", border: "1px solid #FECACA",
fontSize: 13, color: "#991B1B", fontWeight: 500,
textAlign: "center",
}}>
{error}
</div>
)}
{/* content */}
{!loading && user && (
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
{/* avatar + name + status row */}
<div style={{
display: "flex", alignItems: "center", gap: "1rem",
padding: "0 0 1.25rem",
borderBottom: "1px solid var(--color-border)",
marginBottom: "0.25rem",
}}>
<Avatar name={user.name} />
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{user.name}</p>
{user.userName && (
<p style={{ marginTop: 3, fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
@{user.userName}
</p>
)}
<div style={{ marginTop: 8 }}>
<StatusBadge active={user.isActive} />
</div>
</div>
</div>
{/* detail rows */}
<DetailRow label="رقم الهاتف" value={user.phone} />
<DetailRow label="البريد الإلكتروني" value={user.email} />
<DetailRow label="تاريخ الإنشاء" value={fmt(user.createdAt)} />
<DetailRow label="آخر تحديث" value={fmt(user.updatedAt)} />
</div>
)}
</div>
{/* ── footer ── */}
<div style={{
padding: "1rem 1.5rem",
borderTop: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", justifyContent: "flex-end",
}}>
<button
type="button" onClick={onClose}
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
إغلاق
</button>
</div>
</div>
</div>
);
}

View File

@@ -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<string | null>(null);
const {
users, loading, total, pages, error,
page, search,
setPage, handleSearch, clearError,
} = useArchivedUsers();
return (
<div
role="dialog" aria-modal="true" aria-labelledby="archive-modal-title"
onClick={e => { 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 && (
<ArchivedUserDetailModal userId={viewUserId} onClose={() => setViewUserId(null)} />
)}
<div
onClick={e => 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 */}
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="archive-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
المستخدمون المؤرشفون
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</h2>
</div>
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
{/* body */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث بالاسم أو الهاتف..."
value={search}
onChange={e => 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)",
}}
/>
</div>
{error && <Alert type="error" message={error} onClose={clearError} />}
<ArchivedUserTable
users={users}
loading={loading}
search={search}
page={page}
pages={pages}
onView={user => setViewUserId(user.id)}
onPageChange={setPage}
/>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: active ? "1px solid #BBF7D0" : "1px solid #FECACA", background: active ? "#DCFCE7" : "#FEF2F2", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: active ? "#166534" : "#991B1B" }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
{active ? "نشط" : "معطل"}
</span>
);
}
// ── icon button ──────────────────────────────────────────────────────────────
function IconBtn({ onClick, title, color, bg, borderColor, children }: { onClick: () => void; title: string; color: string; bg: string; borderColor: string; children: React.ReactNode }) {
return (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
// ── 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 (
<div style={cardStyle}>
{/* column headers */}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}>
<span>الاسم</span>
<span>اسم المستخدم</span>
<span>الحالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>آخر تحديث</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{/* loading state */}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : users.length === 0 ? (
/* empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد مستخدمون في الأرشيف."}
</p>
</div>
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{users.map((u, i) => (
<li key={u.id} style={{
display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px",
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,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{u.name}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{u.phone}</p>
</div>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{u.userName ?? "—"}</span>
<StatusBadge active={u.isActive} />
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(u.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(u.updatedAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view only — delete/restore not implemented yet */}
<IconBtn title={`عرض ${u.name}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(u)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
</div>
</li>
))}
</ul>
)}
{/* pagination */}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
].map(btn => (
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -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 (
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
padding: "0.6rem 0",
borderBottom: "1px solid var(--color-border)",
}}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>{label}</span>
<span style={{
fontSize: 13,
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
color: warn ? "#D97706" : "var(--color-text-primary)",
fontWeight: warn ? 600 : 400,
}}>
{warn && value !== "—" ? "⚠ " : ""}{value}
</span>
</div>
);
}
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 (
<div
role="dialog" aria-modal="true" aria-labelledby="archived-car-detail-title"
onClick={e => { 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",
}}
>
<div
onClick={e => 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 */}
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
مركبة مؤرشفة
</p>
<h2 id="archived-car-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{car ? `${car.manufacturer} ${car.model}` : "عرض المركبة"}
</h2>
</div>
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
{/* body */}
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }} dir="rtl">
{loading && (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
)}
{!loading && error && (
<div style={{ padding: "1rem 1.25rem", borderRadius: "var(--radius-lg)", background: "#FEF2F2", border: "1px solid #FECACA", fontSize: 13, color: "#991B1B", fontWeight: 500, textAlign: "center" }}>
{error}
</div>
)}
{!loading && car && (
<>
{/* status badges */}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1.25rem" }}>
{(() => {
const s = STATUS_MAP[car.currentStatus];
return (
<span style={{ borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: s.color }}>
{s.label}
</span>
);
})()}
{car.insuranceStatus && (() => {
const ins = INS_MAP[car.insuranceStatus as InsuranceStatus];
return (
<span style={{ borderRadius: "var(--radius-full)", border: `1px solid ${ins.color}33`, background: `${ins.color}11`, padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: ins.color }}>
تأمين: {ins.label}
</span>
);
})()}
<span style={{ borderRadius: "var(--radius-full)", border: "1px solid #FECACA", background: "#FEF2F2", padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: "#DC2626" }}>
مؤرشفة
</span>
</div>
<DetailRow label="رقم اللوحة" value={`${car.plateLetters} ${car.plateNumber}`} mono />
<DetailRow label="الفرع" value={car.branch?.name ?? "—"} />
<DetailRow label="رقم الاستمارة" value={car.registrationNumber ?? "—"} mono />
<DetailRow label="رقم الهيكل (VIN)" value={car.vinNumber ?? "—"} mono />
<DetailRow label="انتهاء الاستمارة" value={fmtDate(car.registrationExpiryDate)} warn={isExpiringSoon(car.registrationExpiryDate)} />
<DetailRow label="انتهاء التأمين" value={fmtDate(car.insuranceExpiryDate)} warn={isExpiringSoon(car.insuranceExpiryDate)} />
<DetailRow label="تاريخ الإضافة" value={fmtDate(car.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(car.updatedAt)} />
</>
)}
</div>
{/* footer */}
<div style={{ padding: "1rem 1.5rem", borderTop: "1px solid var(--color-border)", background: "var(--color-surface-muted)", display: "flex", justifyContent: "flex-end" }}>
<button type="button" onClick={onClose}
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
إغلاق
</button>
</div>
</div>
</div>
);
}

View File

@@ -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<Car | null>(null);
const {
cars, loading, total, pages, error,
page, search,
setPage, handleSearch, setError,
} = useArchivedCars();
return (
<div
role="dialog" aria-modal="true" aria-labelledby="archive-cars-modal-title"
onClick={e => { 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 && (
<ArchivedCarDetailPanel
car={viewCar}
loading={false}
error={null}
onClose={() => setViewCar(null)}
/>
)}
<div
onClick={e => 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 */}
<div dir="rtl" style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>
الأرشيف
</p>
<h2 id="archive-cars-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
المركبات المؤرشفة
<span style={{ marginInlineStart: 8, fontSize: 13, fontWeight: 500, color: "var(--color-text-muted)" }}>
({total})
</span>
</h2>
</div>
<button type="button" onClick={onClose} aria-label="إغلاق"
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
×
</button>
</div>
{/* body */}
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{/* search */}
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
</svg>
<input
type="text"
placeholder="بحث بالماركة أو اللوحة..."
value={search}
onChange={e => 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)",
}}
/>
</div>
{error && <Alert type="error" message={error} onClose={() => setError(null)} />}
<ArchivedCarTable
cars={cars}
loading={loading}
search={search}
page={page}
pages={pages}
onView={car => setViewCar(car)}
onPageChange={setPage}
/>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<button type="button" title={title} aria-label={title} onClick={e => { e.stopPropagation(); onClick(); }}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: `1px solid ${borderColor}`, background: bg, color, cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center", transition: "opacity 150ms" }}>
{children}
</button>
);
}
// ── 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 (
<div style={cardStyle}>
{/* column headers */}
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px", ...thStyle }}>
<span>المركبة</span>
<span>رقم اللوحة</span>
<span>الفرع</span>
<span>آخر حالة</span>
<span style={{ textAlign: "center" }}>تاريخ الإضافة</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{/* loading state */}
{loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : cars.length === 0 ? (
/* empty state */
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد مركبات في الأرشيف."}
</p>
</div>
) : (
/* data rows */
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{cars.map((c, i) => {
const status = STATUS_MAP[c.currentStatus];
return (
<li key={c.id} style={{
display: "grid", gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 100px",
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,
}}>
<div>
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>
{c.manufacturer} {c.model}
</p>
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{c.year}{c.color ? ` · ${c.color}` : ""}</p>
</div>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
{c.plateLetters} {c.plateNumber}
</span>
<span style={{ color: "var(--color-text-secondary)" }}>{c.branch?.name ?? "—"}</span>
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: `1px solid ${status.border}`, background: status.bg, padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 700, color: status.color, width: "fit-content" }}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: status.dot }} />
{status.label}
</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{fmtDateShort(c.createdAt)}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
{/* view only — delete/restore not implemented yet */}
<IconBtn title={`عرض ${c.manufacturer} ${c.model}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(c)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</IconBtn>
</div>
</li>
);
})}
</ul>
)}
{/* pagination */}
{pages > 1 && (
<div dir="rtl" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem" }}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
].map(btn => (
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
style={{ borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", padding: "0.375rem 0.875rem", fontSize: 12, color: "var(--color-text-secondary)", cursor: btn.disabled ? "not-allowed" : "pointer", opacity: btn.disabled ? 0.4 : 1, fontFamily: "var(--font-sans)" }}>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -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<Car[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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,
};
}

View File

@@ -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<ArchivedDriver[]>([]);
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<string | null>(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,
};
}

View File

@@ -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<ArchivedOrder[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [error, setError] = useState<string | null>(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,
};
}

View File

@@ -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<Trip[]>([]);
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<string | null>(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,
};
}

View File

@@ -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<ArchivedUser[]>([]);
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<string | null>(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,
};
}

View File

@@ -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<ArchivedDriverListResponse>(
`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<ArchivedDriverResponse>(`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<ArchivedDriverStatusHistoryResponse>(`driver/archived/driverStatus/${id}`, token),
};

View File

@@ -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<ArchivedOrderListResponse>("orders/archived", token),
};

View File

@@ -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<ArchivedTripListResponse>(
`trip/archived${buildArchivedQuery(page, search)}`,
token,
),
/** Get a single archived trip by id */
getById: (id: string, token: string | null) =>
get<ArchivedTripResponse>(`trip/archived/${id}`, token),
};

View File

@@ -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<ArchivedUserListResponse>(
`users/archived${buildArchivedQuery(page, search)}`,
token,
),
/** Get a single archived user by id */
getById: (id: string, token: string | null) =>
get<ArchivedUserResponse>(`users/archived/${id}`, token),
// NOTE: delete/restore intentionally left out for now — see ticket follow-up.
};

View File

@@ -126,3 +126,61 @@ export const DRIVER_CARD_TYPE_MAP: Record<DriverCardType, string> = {
Restricted: "مقيدة", 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[];
}

View File

@@ -107,3 +107,33 @@ export interface OrdersResponse {
totalPages: 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[];
}

View File

@@ -155,3 +155,21 @@ export const TRIP_STATUS_MAP: Record<
Cancelled: { label: "ملغاة", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA", dot: "#DC2626" }, 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;
};
};
}

View File

@@ -66,3 +66,49 @@ export type TableAction =
role: { id?: string; name: string; description?: string }; role: { id?: string; name: string; description?: string };
branch: { id?: string; name: 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<T> 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 }[];
};
}