add archive layout pages to user car driver trip order
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
// app/(dashboard)/cars/page.tsx
|
||||
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { Spinner, Alert } from "@/src/Components/UI";
|
||||
import { CarFormModal } from "@/src/Components/car/CarFormModal";
|
||||
import { CarDetailPanel } from "@/src/Components/car/CarDetailPanel";
|
||||
import { CarDeleteModal } from "@/src/Components/car/CarDeleteModal";
|
||||
import { Spinner, Alert, ArchiveButton } from "@/src/Components/UI";
|
||||
import { CarFormModal } from "@/src/Components/car/CarFormModal";
|
||||
import { CarDetailPanel } from "@/src/Components/car/CarDetailPanel";
|
||||
import { CarDeleteModal } from "@/src/Components/car/CarDeleteModal";
|
||||
import { ArchivedCarsModal } from "@/src/Components/car/archive/Archivedcarsmodal";
|
||||
import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars";
|
||||
import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/src/types/car";
|
||||
import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } from "@/src/types/car";
|
||||
@@ -169,6 +170,7 @@ export default function CarsPage() {
|
||||
const [detailId, setDetailId] = useState<string | null>(null);
|
||||
const [formTarget, setFormTarget] = useState<Car | null | false>(false); // false = closed
|
||||
const [deleteTarget, setDeleteTarget] = useState<Car | null>(null);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
|
||||
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">
|
||||
|
||||
{/* ── Header ── */}
|
||||
@@ -400,6 +406,9 @@ export default function CarsPage() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Floating button to open the archive browser */}
|
||||
<ArchiveButton onClick={() => setArchiveOpen(true)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Alert, Spinner } from "@/src/Components/UI";
|
||||
import { Alert, Spinner, ArchiveButton } from "@/src/Components/UI";
|
||||
import { DriverFormModal } from "@/src/Components/Driver/DriverFormModal";
|
||||
import { DriverDeleteModal } from "@/src/Components/Driver/DriverDeleteModal";
|
||||
import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers";
|
||||
import { useDrivers } from "@/src/hooks/useDriver";
|
||||
import { CreateDriverPayload, Driver, DRIVER_STATUS_MAP, UpdateDriverPayload } from "@/src/types/driver";
|
||||
import { DriverDetailPanel } from "@/src/Components/Driver/DriverDetailPanel";
|
||||
@@ -74,6 +75,8 @@ export default function DriversPage() {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
// Bumped after a successful edit to force the detail panel to re-fetch
|
||||
const [panelRefreshKey, setPanelRefreshKey] = useState(0);
|
||||
// Archive browser modal open/closed
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||
const handleEdit = useCallback((driver: Driver) => {
|
||||
@@ -418,6 +421,14 @@ export default function DriversPage() {
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Archive browser modal ── */}
|
||||
{archiveOpen && (
|
||||
<ArchivedDrivers onClose={() => setArchiveOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* ── Floating button to open the archive browser ── */}
|
||||
<ArchiveButton onClick={() => setArchiveOpen(true)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,11 @@
|
||||
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { Alert, Spinner, Toast } from "@/src/Components/UI";
|
||||
import { OrderFormModal } from "@/src/Components/Order/Orderformmodal";
|
||||
import { OrderDeleteModal } from "@/src/Components/Order/Orderdeletemodal";
|
||||
import { Alert, Spinner, Toast, ArchiveButton } from "@/src/Components/UI";
|
||||
import { OrderFormModal } from "@/src/Components/Order/OrderFormModal";
|
||||
import { OrderDeleteModal } from "@/src/Components/Order/OrderDeleteModal";
|
||||
import { OrderDetailPanel, ORDER_STATUS_MAP } from "@/src/Components/Order/OrderDetailBanel";
|
||||
import { ArchivedOrdersModal } from "@/src/Components/Order/archive/ArchivedOrdersModal";
|
||||
import { useOrders } from "@/src/hooks/useOrder";
|
||||
import type { CreateOrderPayload, Order, UpdateOrderPayload } from "@/src/types/order";
|
||||
|
||||
@@ -81,6 +82,8 @@ export default function OrderComponent() {
|
||||
// Bumped after a successful edit/status-change to force the detail panel
|
||||
// to re-fetch — same trick as DriversPage's panelRefreshKey.
|
||||
const [panelRefreshKey, setPanelRefreshKey] = useState(0);
|
||||
// Archive browser modal open/closed
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────
|
||||
const handleEdit = useCallback((order: Order) => {
|
||||
@@ -447,12 +450,20 @@ export default function OrderComponent() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Archive browser modal ── */}
|
||||
{archiveOpen && (
|
||||
<ArchivedOrdersModal onClose={() => setArchiveOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* ── Toast: transient success/error feedback for create, update,
|
||||
delete, and status-change actions — sourced from useOrders()'
|
||||
`notification` state (see useOrders.ts notify()). This is the
|
||||
same toast used by the Driver pages via DriverDeleteModal's
|
||||
parent and useDriver.ts, kept consistent here. ── */}
|
||||
<Toast notification={notification} />
|
||||
|
||||
{/* Floating button to open the archive browser */}
|
||||
<ArchiveButton onClick={() => setArchiveOpen(true)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import { tripService } from "@/src/services/trip.service";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
|
||||
import { TripDeleteModal } from "@/src/Components/Trip/Tripdeletemodal";
|
||||
import { Spinner } from "@/src/Components/UI";
|
||||
import { ArchivedTripList } from "@/src/Components/Trip/archive/ArchivedTripList";
|
||||
import { Spinner, ArchiveButton } from "@/src/Components/UI";
|
||||
import { Toast, type ToastNotification } from "@/src/Components/UI/Toast";
|
||||
import type { Trip, TripStatus, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip";
|
||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||
@@ -51,6 +52,67 @@ function StatusBadge({ status }: { status: TripStatus }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Archived trips modal ─────────────────────────────────────────────────────
|
||||
// Lightweight wrapper modal around <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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TripsPage() {
|
||||
@@ -93,6 +155,8 @@ export default function TripsPage() {
|
||||
const [editTrip, setEditTrip] = useState<Trip | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Trip | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
// Archive browser modal open/closed
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
|
||||
// ── Create / update — calls tripService directly and notifies explicitly ──
|
||||
const handleSubmit = async (
|
||||
@@ -146,22 +210,42 @@ export default function TripsPage() {
|
||||
إدارة جدولة الرحلات وتتبع حالتها
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setEditTrip(null); setShowForm(true); }}
|
||||
style={{
|
||||
height: 40, padding: "0 1.25rem",
|
||||
borderRadius: "var(--radius-md)", border: "none",
|
||||
background: "var(--color-brand-600)", color: "#FFF",
|
||||
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.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
إضافة رحلة
|
||||
</button>
|
||||
<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
|
||||
onClick={() => { setEditTrip(null); setShowForm(true); }}
|
||||
style={{
|
||||
height: 40, padding: "0 1.25rem",
|
||||
borderRadius: "var(--radius-md)", border: "none",
|
||||
background: "var(--color-brand-600)", color: "#FFF",
|
||||
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.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
إضافة رحلة
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Filters ── */}
|
||||
@@ -367,6 +451,9 @@ export default function TripsPage() {
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
{archiveOpen && (
|
||||
<ArchivedTripsModal onClose={() => setArchiveOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* ── Toast ── */}
|
||||
<Toast notification={notification} onDismiss={dismissNotification} />
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Toast } from "@/src/Components/UI";
|
||||
import { Alert, Toast, ArchiveButton } from "@/src/Components/UI";
|
||||
import { UserTable } from "@/src/Components/User/UserTable";
|
||||
import { UserFormModal } from "@/src/Components/User/UserFormModal";
|
||||
import { UserDetailModal } from "@/src/Components/User/UserDetailModal";
|
||||
import { DeleteConfirmModal } from "@/src/Components/User/DeleteConfirmModal";
|
||||
import { useUsers } from "@/src/hooks/useUser";
|
||||
import type { User, UserFormData } from "@/src/types/user";
|
||||
import { ArchivedUsersModal } from "@/src/Components/User/archive/Archivedusersmodal";
|
||||
|
||||
export default function UsersPage() {
|
||||
// ── Modal state ─────────────────────────────────────────────────────────────
|
||||
@@ -16,6 +17,8 @@ export default function UsersPage() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
||||
// ID of user whose detail modal is open; null = closed
|
||||
const [viewUserId, setViewUserId] = useState<string | null>(null);
|
||||
// Archive browser modal open/closed
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
// Local submitting flag shown in DeleteConfirmModal spinner
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
@@ -40,9 +43,9 @@ export default function UsersPage() {
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget || deleting) return;
|
||||
setDeleting(true);
|
||||
|
||||
|
||||
const ok = await deleteUser(deleteTarget.id);
|
||||
|
||||
|
||||
setDeleting(false);
|
||||
if (ok) setDeleteTarget(null);
|
||||
};
|
||||
@@ -84,6 +87,11 @@ export default function UsersPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Archive browser modal */}
|
||||
{archiveOpen && (
|
||||
<ArchivedUsersModal onClose={() => setArchiveOpen(false)} />
|
||||
)}
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||
|
||||
{/* ── Page header ── */}
|
||||
@@ -178,6 +186,9 @@ export default function UsersPage() {
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Floating button to open the archive browser */}
|
||||
<ArchiveButton onClick={() => setArchiveOpen(true)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
301
src/Components/Driver/archive/ArchivedDriverDetailModal.tsx
Normal file
301
src/Components/Driver/archive/ArchivedDriverDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
src/Components/Driver/archive/ArchivedDriverTable.tsx
Normal file
141
src/Components/Driver/archive/ArchivedDriverTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
119
src/Components/Driver/archive/ArchivedDrivers.tsx
Normal file
119
src/Components/Driver/archive/ArchivedDrivers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
src/Components/Order/archive/ArchivedOrderDetailModal.tsx
Normal file
85
src/Components/Order/archive/ArchivedOrderDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
src/Components/Order/archive/ArchivedOrderTable.tsx
Normal file
123
src/Components/Order/archive/ArchivedOrderTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
src/Components/Order/archive/ArchivedOrdersModal.tsx
Normal file
115
src/Components/Order/archive/ArchivedOrdersModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
src/Components/Trip/archive/ArchivedTripList.tsx
Normal file
167
src/Components/Trip/archive/ArchivedTripList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/Components/UI/ArchiveButton.tsx
Normal file
61
src/Components/UI/ArchiveButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -21,4 +21,6 @@ export { Modal } from "./ModalProps";
|
||||
export { ConfirmDialog } from "./ConfirmDialog";
|
||||
export { PageHeader } from "./PageHeader";
|
||||
export { EmptyState } from "./EmptyState";
|
||||
export { Badge } from "./Badge";
|
||||
export { Badge } from "./Badge";
|
||||
|
||||
export { ArchiveButton } from "./ArchiveButton"
|
||||
235
src/Components/User/archive/Archiveduserdetailmodal.tsx
Normal file
235
src/Components/User/archive/Archiveduserdetailmodal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
src/Components/User/archive/Archivedusersmodal.tsx
Normal file
114
src/Components/User/archive/Archivedusersmodal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
src/Components/User/archive/Archivedusertable.tsx
Normal file
134
src/Components/User/archive/Archivedusertable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
src/Components/car/archive/Archivedcardetailpanel.tsx
Normal file
149
src/Components/car/archive/Archivedcardetailpanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
src/Components/car/archive/Archivedcarsmodal.tsx
Normal file
120
src/Components/car/archive/Archivedcarsmodal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
src/Components/car/archive/Archivedcartable.tsx
Normal file
133
src/Components/car/archive/Archivedcartable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/hooks/archive/Usearchivedcars.ts
Normal file
65
src/hooks/archive/Usearchivedcars.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
49
src/hooks/archive/useArchivedDrivers.ts
Normal file
49
src/hooks/archive/useArchivedDrivers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
58
src/hooks/archive/useArchivedOrders.ts
Normal file
58
src/hooks/archive/useArchivedOrders.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
45
src/hooks/archive/useArchivedTrips.ts
Normal file
45
src/hooks/archive/useArchivedTrips.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
43
src/hooks/archive/useArchivedUsers.ts
Normal file
43
src/hooks/archive/useArchivedUsers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
46
src/services/archive/archivedDriver.service.ts
Normal file
46
src/services/archive/archivedDriver.service.ts
Normal 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),
|
||||
};
|
||||
8
src/services/archive/archivedOrder.service.ts
Normal file
8
src/services/archive/archivedOrder.service.ts
Normal 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),
|
||||
};
|
||||
20
src/services/archive/archivedTrip.service.ts
Normal file
20
src/services/archive/archivedTrip.service.ts
Normal 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),
|
||||
};
|
||||
25
src/services/archive/archivedUser.service.ts
Normal file
25
src/services/archive/archivedUser.service.ts
Normal 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.
|
||||
};
|
||||
@@ -126,3 +126,61 @@ export const DRIVER_CARD_TYPE_MAP: Record<DriverCardType, string> = {
|
||||
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[];
|
||||
}
|
||||
@@ -106,4 +106,34 @@ export interface OrdersResponse {
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
// Archived order resource returned by /orders/archived.
|
||||
// NOTE: sample payload has no pagination meta — adjust if backend confirms otherwise.
|
||||
export interface ArchivedOrder {
|
||||
id: string;
|
||||
shipmentNumber: string;
|
||||
recipientName: string;
|
||||
recipientPhone: string;
|
||||
clientId: string;
|
||||
tripId?: string | null;
|
||||
pickupAddressId?: string | null;
|
||||
deliveryAddressId?: string | null;
|
||||
currentStatus: OrderStatus;
|
||||
quantity: number;
|
||||
subTotal?: number | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
statusHistory?: Array<{
|
||||
id: string;
|
||||
status: OrderStatus;
|
||||
reason?: string | null;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ArchivedOrderListResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
responseAt: string;
|
||||
data: ArchivedOrder[];
|
||||
}
|
||||
@@ -155,3 +155,21 @@ export const TRIP_STATUS_MAP: Record<
|
||||
Cancelled: { label: "ملغاة", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA", dot: "#DC2626" },
|
||||
};
|
||||
|
||||
// ── Archived Trip types (mirrors ArchivedUser pattern in types/user.ts) ─────
|
||||
// GET /trip/archived/{id}
|
||||
export interface ArchivedTripResponse {
|
||||
data: Trip;
|
||||
}
|
||||
|
||||
// GET /trip/archived — paginated list
|
||||
export interface ArchivedTripListResponse {
|
||||
data: {
|
||||
data: Trip[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -65,4 +65,50 @@ export type TableAction =
|
||||
passwordChangedAt: string | null;
|
||||
role: { id?: string; name: string; description?: string };
|
||||
branch: { id?: string; name: string };
|
||||
}
|
||||
|
||||
// Archived user resource returned by the archive endpoints
|
||||
export interface ArchivedUser {
|
||||
id: string;
|
||||
name: string;
|
||||
userName?: string;
|
||||
email?: string;
|
||||
phone: string;
|
||||
photo: string | null;
|
||||
isActive: boolean;
|
||||
isDeleted: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// GET /v1/users/archived/{id}
|
||||
export interface ArchivedUserResponse {
|
||||
data: ArchivedUser;
|
||||
}
|
||||
|
||||
// GET /v1/users/archived (note: this endpoint uses `meta`, not `pagination`,
|
||||
// unlike the live users list — kept as a distinct shape rather than reusing
|
||||
// ApiListResponse<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 }[];
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user