The trip files have been reorganized. Errors related to vehicle and driver editing have been resolved, archive display issues fixed, and model data display standardized across the project; order and trip-related issues have also been addressed.
This commit is contained in:
@@ -146,7 +146,20 @@ const GRAD = "linear-gradient(175deg, #1E3A8A 0%, #1D4ED8 60%, #1565C0 100%)";
|
||||
══════════════════════════════════════════ */
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const user = getStoredUser();
|
||||
|
||||
// ⚠️ التقاط اليوزر بعد الـ mount بس، عشان أول render على الكلاينت
|
||||
// يتطابق مع السيرفر (فاضي) ويتجنب hydration mismatch
|
||||
const [user, setUser] = useState<ReturnType<typeof getStoredUser>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setUser(getStoredUser());
|
||||
}, []);
|
||||
|
||||
// إعادة القراءة بعد أي navigation (زي بعد الحفظ لو الصلاحيات اتغيرت)
|
||||
useEffect(() => {
|
||||
setUser(getStoredUser());
|
||||
}, [pathname]);
|
||||
|
||||
const permissions = user?.permissions ?? [];
|
||||
const { open, setOpen } = useSidebarDrawer();
|
||||
|
||||
@@ -363,7 +376,6 @@ function SidebarContent({
|
||||
pathname.startsWith(item.href + "/"));
|
||||
return (
|
||||
<Link
|
||||
suppressHydrationWarning
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
title={compact ? item.label : undefined}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Spinner,
|
||||
ArchiveButton,
|
||||
ConfirmDialog,
|
||||
Toast,
|
||||
} from "@/src/Components/UI";
|
||||
import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers";
|
||||
import { useDrivers } from "@/src/hooks/useDriver";
|
||||
@@ -87,6 +88,7 @@ export default function DriversPage() {
|
||||
updateDriver,
|
||||
deleteDriver,
|
||||
notification,
|
||||
clearNotification,
|
||||
} = useDrivers();
|
||||
|
||||
// ── Panel / modal state ───────────────────────────────────────────────────
|
||||
@@ -174,9 +176,7 @@ export default function DriversPage() {
|
||||
/>
|
||||
|
||||
{/* ── Notifications ── */}
|
||||
{notification && (
|
||||
<Alert type={notification.type} message={notification.message} />
|
||||
)}
|
||||
<Toast notification={notification} onDismiss={clearNotification} />
|
||||
{error && <Alert type="error" message={error} onClose={clearError} />}
|
||||
|
||||
{/* ── Table ── */}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
"use client";
|
||||
|
||||
// app/dashboard/trips/[tripId]/page.tsx
|
||||
// CHANGE: TripFormModal now imported from the barrel (@/src/Components/Trip)
|
||||
// instead of a direct path — avoids the Tripformmodal.tsx case-sensitivity
|
||||
// mismatch that breaks on case-sensitive filesystems (Linux/Docker builds).
|
||||
// CHANGE: loadTrip's catch block now uses the same extractApiMessage helper
|
||||
// already defined in this file (and used by handleEditSubmit/handleConfirmDelete)
|
||||
// instead of `err instanceof Error ? err.message : ...` — so backend
|
||||
// validation messages surface correctly here too, not just on edit/delete.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { ConfirmDialog, Spinner } from "@/src/Components/UI";
|
||||
import { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
|
||||
import { TripFormModal } from "@/src/Components/Trip";
|
||||
import { TripReportPanel } from "@/src/Components/Trip_Report/Tripreportpanel";
|
||||
import { tripService } from "@/src/services/trip.service";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
@@ -213,11 +222,11 @@ export default function TripDetailPage() {
|
||||
const data = await tripService.getById(tripId, token);
|
||||
setTrip(data);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات الرحلة.");
|
||||
setError(extractApiMessage(err, "تعذّر تحميل بيانات الرحلة."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tripId]);
|
||||
}, [tripId]);
|
||||
useEffect(() => {
|
||||
queueMicrotask(loadTrip);
|
||||
}, [loadTrip]);
|
||||
@@ -240,7 +249,7 @@ export default function TripDetailPage() {
|
||||
}
|
||||
},
|
||||
[trip, notify],
|
||||
);
|
||||
);
|
||||
|
||||
// ── Delete confirm ────────────────────────────────────────────────────────
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
|
||||
@@ -1,110 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Spinner, EmptyState, Badge, PageHeader } from "@/src/Components/UI";
|
||||
import { tripService } from "@/src/services/trip.service";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import type { Trip } from "@/src/types/trip";
|
||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||
import { ArchivedTripDetailModal } from "@/src/Components/Trip/archive/ArchivedtripDetailModal";
|
||||
// app/dashboard/trips/archived/[tripId]/page.tsx
|
||||
// CHANGE: rebuilt as a thin route wrapper around the shared
|
||||
// ArchivedTripDetailModal instead of its own independent fetch/loading/error/
|
||||
// render logic. This route now shows the exact same full detail view as the
|
||||
// in-app archive browser (driver + car + counts + cash + notes + endReason,
|
||||
// not just the handful of fields this page used to render on its own), and
|
||||
// stays in sync automatically with any future changes to that component.
|
||||
// `onClose` navigates back to the trips list (via router.push) instead of
|
||||
// closing an in-place modal, since this route has no page behind it to
|
||||
// reveal.
|
||||
|
||||
function fmtDateTime(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleString("ar-SA", { dateStyle: "medium", timeStyle: "short" });
|
||||
}
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
||||
export default function ArchivedTripDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const tripId = params?.tripId as string;
|
||||
|
||||
const [trip, setTrip] = useState<Trip | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tripId) return;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const data = await tripService.getArchivedById(tripId, token);
|
||||
if (!cancelled) setTrip(data);
|
||||
} catch {
|
||||
if (!cancelled) setError("لم يتم العثور على هذه الرحلة في الأرشيف.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [tripId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3 py-24 text-[var(--color-text-muted)]">
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm">جارٍ التحميل…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !trip) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="🗄️"
|
||||
title="الرحلة غير موجودة في الأرشيف"
|
||||
description={error ?? "تعذّر العثور على رحلة بهذا المعرّف."}
|
||||
action={
|
||||
<button
|
||||
onClick={() => router.push("/dashboard/trips")}
|
||||
className="text-sm font-semibold text-[var(--color-brand-600)] underline"
|
||||
>
|
||||
العودة إلى قائمة الرحلات
|
||||
</button>
|
||||
}
|
||||
<ArchivedTripDetailModal
|
||||
tripId={tripId}
|
||||
onClose={() => router.push("/dashboard/trips")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const statusConfig = TRIP_STATUS_MAP[trip.status];
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-6 p-6" dir="rtl">
|
||||
<PageHeader
|
||||
title={trip.title}
|
||||
description={`رقم الرحلة: ${trip.tripNumber}`}
|
||||
backHref="/dashboard/trips"
|
||||
backLabel="الرحلات"
|
||||
action={<Badge label="مؤرشفة" color="amber" />}
|
||||
/>
|
||||
|
||||
<div className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-[var(--shadow-card)]">
|
||||
<Badge label={statusConfig.label} color="slate" />
|
||||
<dl className="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<dt className="text-[var(--color-text-muted)]">وقت البدء</dt>
|
||||
<dd>{fmtDateTime(trip.startTime)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--color-text-muted)]">وقت الانتهاء</dt>
|
||||
<dd>{fmtDateTime(trip.endTime)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--color-text-muted)]">تاريخ الأرشفة</dt>
|
||||
<dd>{fmtDateTime(trip.deletedAt)}</dd>
|
||||
</div>
|
||||
{trip.driver && (
|
||||
<div>
|
||||
<dt className="text-[var(--color-text-muted)]">السائق</dt>
|
||||
<dd>{trip.driver.name}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +1,31 @@
|
||||
"use client";
|
||||
|
||||
// app/dashboard/trips/page.tsx
|
||||
// CHANGE: replaced the old hand-rolled inline table with <TripTable/>.
|
||||
// CHANGE: TripFormModal now imported from the barrel (@/src/Components/Trip)
|
||||
// instead of a direct path — avoids the Tripformmodal.tsx case-sensitivity
|
||||
// mismatch that breaks on case-sensitive filesystems (Linux/Docker builds).
|
||||
// CHANGE: removed the local ArchivedTripsModal wrapper (it duplicated what
|
||||
// the new ArchivedTripModal already does — search bar + table + detail) and
|
||||
// removed the ArchivedTripList import; now renders <ArchivedTripModal/>
|
||||
// directly.
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTrips } from "@/src/hooks/useTrip";
|
||||
import { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
|
||||
import { ArchivedTripList } from "@/src/Components/Trip/archive/ArchivedTripList";
|
||||
import { Alert, Spinner, ArchiveButton, ConfirmDialog , Toast } from "@/src/Components/UI";
|
||||
import type {
|
||||
Trip,
|
||||
TripStatus,
|
||||
CreateTripPayload,
|
||||
UpdateTripPayload,
|
||||
} from "@/src/types/trip";
|
||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||
import { TripFormModal, TripTable } from "@/src/Components/Trip";
|
||||
import { ArchivedTripModal } from "@/src/Components/Trip/archive/ArchivedTripModal";
|
||||
import { Alert, ArchiveButton, ConfirmDialog, Toast } from "@/src/Components/UI";
|
||||
import type { Trip, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip";
|
||||
import Header from "@/src/Components/UI/Header";
|
||||
|
||||
// ── Status badge — same visual pattern as Driver's status badge ─────────────
|
||||
|
||||
function StatusBadge({ status }: { status: TripStatus }) {
|
||||
const s = TRIP_STATUS_MAP[status];
|
||||
return (
|
||||
<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,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Archived trips modal — unchanged wrapper, kept as-is ────────────────────
|
||||
|
||||
function ArchivedTripsModal({ onClose }: { onClose: () => void }) {
|
||||
const router = useRouter();
|
||||
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
<div style={{ padding: "1.5rem" }}>
|
||||
{/* Was pointing at the normal trip page — send it to the archived route instead */}
|
||||
<ArchivedTripList
|
||||
onView={(trip) =>
|
||||
router.push(`/dashboard/trips/archived/${trip.id}`)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TripsPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// All list state + mutations now come from the hook, same as useDrivers
|
||||
// is used on the Driver page. No direct tripService calls in this file.
|
||||
// All list state + mutations come from the hook, same as useDrivers is
|
||||
// used on the Driver page. No direct tripService calls in this file.
|
||||
const {
|
||||
trips,
|
||||
total,
|
||||
@@ -169,8 +36,6 @@ export default function TripsPage() {
|
||||
setPage,
|
||||
search,
|
||||
handleSearch,
|
||||
status,
|
||||
handleStatusFilter,
|
||||
createTrip,
|
||||
updateTrip,
|
||||
deleteTrip,
|
||||
@@ -202,6 +67,19 @@ export default function TripsPage() {
|
||||
setDeleteTarget(trip);
|
||||
}, []);
|
||||
|
||||
// TripTable exposes a dedicated "view" action (icon button) instead of a
|
||||
// row click — wired to the trip detail page, same destination the old
|
||||
// row-click handler used to navigate to.
|
||||
const handleView = useCallback(
|
||||
(trip: Trip) => router.push(`/dashboard/trips/${trip.id}`),
|
||||
[router],
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
setEditTrip(null);
|
||||
setShowForm(true);
|
||||
}, []);
|
||||
|
||||
// ── Create / update — delegates to the hook (hook fires the toast) ───────
|
||||
const handleSubmit = async (
|
||||
payload: CreateTripPayload | UpdateTripPayload,
|
||||
@@ -233,7 +111,8 @@ export default function TripsPage() {
|
||||
}}
|
||||
>
|
||||
{/* ── Header ── */}
|
||||
<Header mainTitle="إدارة الرحلات"
|
||||
<Header
|
||||
mainTitle="إدارة الرحلات"
|
||||
title="الرحلات"
|
||||
name="رحلة"
|
||||
state={{ total }}
|
||||
@@ -243,288 +122,27 @@ export default function TripsPage() {
|
||||
setModule={() => {}}
|
||||
setPage={setPage}
|
||||
isAudit={false}
|
||||
onAdd={() => { setEditTrip(null); setShowForm(true); }}/>
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
|
||||
{/* ── Error handling: Alert banner for list/API errors ──
|
||||
Same pattern as the Driver page: a persistent banner tied to the
|
||||
hook's `error` state, dismissible via `clearError`. */}
|
||||
{error && <Alert type="error" message={error} onClose={clearError} />}
|
||||
|
||||
{/* ── Table ── */}
|
||||
<div
|
||||
style={{
|
||||
background: "var(--color-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: "var(--radius-xl)",
|
||||
overflow: "hidden",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
dir="rtl"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1.5fr 2fr 1.3fr 1.3fr 1fr 1.1fr 1.1fr 0.8fr",
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
<span>رقم الرحلة</span>
|
||||
<span>العنوان</span>
|
||||
<span>السائق</span>
|
||||
<span>السيارة</span>
|
||||
<span>الحالة</span>
|
||||
<span>البدء</span>
|
||||
<span>الانتهاء</span>
|
||||
<span>إجراءات</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>
|
||||
) : trips.length === 0 ? (
|
||||
<p
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "4rem 0",
|
||||
fontSize: 13,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
لا توجد رحلات مطابقة {search && `لـ "${search}"`}
|
||||
</p>
|
||||
) : (
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{trips.map((trip, i) => (
|
||||
<li
|
||||
key={trip.id}
|
||||
onClick={() => router.push(`/dashboard/trips/${trip.id}`)}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns:
|
||||
"1.5fr 2fr 1.3fr 1.3fr 1fr 1.1fr 1.1fr 0.8fr",
|
||||
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",
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background =
|
||||
"var(--color-surface-hover, #F8FAFC)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background =
|
||||
i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent")
|
||||
}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 12,
|
||||
color: "var(--color-brand-600)",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{trip.tripNumber}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{trip.title}
|
||||
</span>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||
{trip.driver?.name ?? "—"}
|
||||
</span>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||
{trip.car
|
||||
? `${trip.car.manufacturer} ${trip.car.model}`
|
||||
: "—"}
|
||||
</span>
|
||||
<StatusBadge status={trip.status} />
|
||||
<span
|
||||
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{trip.startTime
|
||||
? new Date(trip.startTime).toLocaleString("ar-SA", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
})
|
||||
: "—"}
|
||||
</span>
|
||||
<span
|
||||
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
|
||||
>
|
||||
{trip.endTime
|
||||
? new Date(trip.endTime).toLocaleString("ar-SA", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
})
|
||||
: "—"}
|
||||
</span>
|
||||
|
||||
{/* Inline row actions — stopPropagation so the click does not
|
||||
also trigger navigation to the detail page. */}
|
||||
<div
|
||||
style={{ display: "flex", gap: "0.4rem" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="تعديل الرحلة"
|
||||
title="تعديل"
|
||||
onClick={() => handleEdit(trip)}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "var(--radius-md)",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
border: "1px solid var(--color-brand-200)",
|
||||
background: "var(--color-brand-50, #EFF6FF)",
|
||||
color: "var(--color-brand-600)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
>
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="حذف الرحلة"
|
||||
title="حذف"
|
||||
onClick={() => handleDelete(trip)}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "var(--radius-md)",
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
border: "1px solid #FECACA",
|
||||
background: "#FEF2F2",
|
||||
color: "#DC2626",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* ── Pagination — same pattern as Driver page ── */}
|
||||
{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>
|
||||
{" — "}
|
||||
{total} رحلة
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{[
|
||||
{
|
||||
label: "السابق",
|
||||
action: () => setPage((p) => Math.max(1, p - 1)),
|
||||
disabled: page === 1,
|
||||
},
|
||||
{
|
||||
label: "التالي",
|
||||
action: () => setPage((p) => Math.min(pages, p + 1)),
|
||||
disabled: page === pages,
|
||||
},
|
||||
].map((btn) => (
|
||||
<button
|
||||
key={btn.label}
|
||||
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>
|
||||
<TripTable
|
||||
trips={trips}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onView={handleView}
|
||||
onAddFirst={handleAdd}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
|
||||
{/* ── Form modal ── */}
|
||||
{showForm && (
|
||||
@@ -549,10 +167,10 @@ export default function TripsPage() {
|
||||
description={`هل أنت متأكد من حذف رحلة ${deleteTarget?.title ?? ""} (${deleteTarget?.tripNumber ?? ""})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||
/>
|
||||
|
||||
{/* ── Archive browser modal ── */}
|
||||
{archiveOpen && (
|
||||
<ArchivedTripsModal onClose={() => setArchiveOpen(false)} />
|
||||
)}
|
||||
{/* ── Archive browser modal — now the split ArchivedTripModal directly,
|
||||
no local wrapper needed since it already ships its own search bar,
|
||||
table, and detail-view wiring. ── */}
|
||||
{archiveOpen && <ArchivedTripModal onClose={() => setArchiveOpen(false)} />}
|
||||
|
||||
{/* ── Floating archive button — same component Driver page uses ── */}
|
||||
<ArchiveButton onClick={() => setArchiveOpen(true)} label="الأرشيف" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import { Alert, Badge, Button, Modal, Spinner } from "../UI";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { branchService } from "@/src/services/branch.service";
|
||||
import type { BranchDetail } from "@/src/types/branch";
|
||||
@@ -11,7 +11,7 @@ interface BranchDetailModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ── small helper components ───────────────────────────────────────────────────
|
||||
// ── small helper components (no template exists for a plain label/value row) ──
|
||||
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div style={{
|
||||
@@ -29,23 +29,7 @@ function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Branch icon badge has no equivalent in the shared UI kit — kept custom.
|
||||
function BranchIcon() {
|
||||
return (
|
||||
<div style={{
|
||||
@@ -69,13 +53,6 @@ export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps)
|
||||
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 branch details on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -91,7 +68,7 @@ export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps)
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [branchId]);
|
||||
}, [branchId]);
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
const fmt = (iso?: string | null) =>
|
||||
@@ -102,60 +79,18 @@ export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps)
|
||||
|
||||
// ── render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div
|
||||
role="dialog" aria-modal="true" aria-labelledby="branch-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",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={branch?.name ?? "عرض الفرع"}
|
||||
subtitle="بيانات الفرع"
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<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: "#2563EB", fontWeight: 600, margin: 0 }}>
|
||||
بيانات الفرع
|
||||
</p>
|
||||
<h2 id="branch-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
||||
{branch?.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)" }}>
|
||||
@@ -165,17 +100,7 @@ export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps)
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{!loading && error && <Alert type="error" message={error} />}
|
||||
|
||||
{/* content */}
|
||||
{!loading && branch && (
|
||||
@@ -197,7 +122,7 @@ export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps)
|
||||
</p>
|
||||
)}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<StatusBadge active={branch.isActive} />
|
||||
<Badge label={branch.isActive ? "نشط" : "معطل"} color={branch.isActive ? "green" : "red"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -219,31 +144,6 @@ export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps)
|
||||
<DetailRow label="آخر تحديث" value={fmt(branch.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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "../UI";
|
||||
import { Badge, Button, EmptyState, IconBtn, Spinner } from "../UI";
|
||||
import type { Branch } from "@/src/types/branch";
|
||||
|
||||
// ── 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)",
|
||||
@@ -71,17 +51,17 @@ export function BranchTable({ branches, loading, search, page, pages, onEdit, on
|
||||
</div>
|
||||
) : branches.length === 0 ? (
|
||||
/* empty state */
|
||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."}
|
||||
</p>
|
||||
{!search && (
|
||||
<button type="button" onClick={onAddFirst}
|
||||
style={{ marginTop: 12, fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
|
||||
<EmptyState
|
||||
icon="🏢"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."}
|
||||
action={
|
||||
!search && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||
أضف أول فرع
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
@@ -99,7 +79,7 @@ export function BranchTable({ branches, loading, search, page, pages, onEdit, on
|
||||
</div>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{b.city || "—"}</span>
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-secondary)" }}>{b.phone ?? "—"}</span>
|
||||
<StatusBadge active={b.isActive} />
|
||||
<Badge label={b.isActive ? "نشط" : "معطل"} color={b.isActive ? "green" : "red"} />
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
@@ -142,10 +122,9 @@ export function BranchTable({ branches, loading, search, page, pages, onEdit, on
|
||||
{ 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)" }}>
|
||||
<Button key={btn.label} type="button" variant="secondary" size="sm" onClick={btn.action} disabled={btn.disabled}>
|
||||
{btn.label}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Badge, Button, Modal } from "../../UI";
|
||||
import type { ArchivedBranch } from "@/src/types/branch";
|
||||
|
||||
interface ArchivedBranchDetailModalProps {
|
||||
@@ -26,23 +26,6 @@ function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
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 BranchIcon() {
|
||||
return (
|
||||
<div style={{
|
||||
@@ -65,13 +48,6 @@ function BranchIcon() {
|
||||
// modal does NOT fetch — it just renders the ArchivedBranch object that was
|
||||
// already loaded from GET /branches/archived (passed down from the table).
|
||||
export function ArchivedBranchDetailModal({ branch, onClose }: ArchivedBranchDetailModalProps) {
|
||||
// close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
const fmt = (iso?: string | null) =>
|
||||
iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null;
|
||||
@@ -81,59 +57,19 @@ export function ArchivedBranchDetailModal({ branch, onClose }: ArchivedBranchDet
|
||||
|
||||
// ── render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div
|
||||
role="dialog" aria-modal="true" aria-labelledby="archived-branch-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",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
subtitle="فرع مؤرشف"
|
||||
title={branch.name}
|
||||
zIndex={60}
|
||||
footer={
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<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-branch-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
||||
{branch.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" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
|
||||
|
||||
{/* icon + name + status row */}
|
||||
@@ -150,7 +86,7 @@ export function ArchivedBranchDetailModal({ branch, onClose }: ArchivedBranchDet
|
||||
#{branch.id}
|
||||
</p>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<StatusBadge active={branch.isActive} />
|
||||
<Badge label={branch.isActive ? "نشط" : "معطل"} color={branch.isActive ? "green" : "red"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,31 +112,6 @@ export function ArchivedBranchDetailModal({ branch, onClose }: ArchivedBranchDet
|
||||
<DetailRow label="تاريخ الإنشاء" value={fmt(branch.createdAt)} />
|
||||
<DetailRow label="آخر تحديث" value={fmt(branch.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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "../../UI";
|
||||
import { Badge, Button, IconBtn, Spinner } from "../../UI";
|
||||
import type { ArchivedBranch } from "@/src/types/branch";
|
||||
|
||||
// ── 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)",
|
||||
@@ -89,7 +69,7 @@ export function ArchivedBranchTable({ branches, loading, search, page, pages, on
|
||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
|
||||
</div>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{b.city || "—"}</span>
|
||||
<StatusBadge active={b.isActive} />
|
||||
<Badge label={b.isActive ? "نشط" : "معطل"} color={b.isActive ? "green" : "red"} />
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
@@ -117,15 +97,22 @@ export function ArchivedBranchTable({ branches, loading, search, page, pages, on
|
||||
صفحة <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>
|
||||
))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert } from "../../UI";
|
||||
import { Alert, Input, Modal } from "../../UI";
|
||||
import { ArchivedBranchTable } from "./ArchivedBranchTable";
|
||||
import { ArchivedBranchDetailModal } from "./ArchivedBranchDetailModal";
|
||||
import { useArchivedBranches } from "@/src/hooks/archive/useArchiveBranch";
|
||||
@@ -11,6 +11,12 @@ interface ArchivedBranchesModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const SearchIcon = (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) {
|
||||
// hold the full object, not just the id — no GET /branches/archived/{id} route exists
|
||||
const [viewBranch, setViewBranch] = useState<ArchivedBranch | null>(null);
|
||||
@@ -22,79 +28,24 @@ export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) {
|
||||
} = useArchivedBranches();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog" aria-modal="true" aria-labelledby="branch-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",
|
||||
}}
|
||||
<>
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
subtitle="الأرشيف"
|
||||
title={`الفروع المؤرشفة (${total})`}
|
||||
>
|
||||
{viewBranch && (
|
||||
<ArchivedBranchDetailModal branch={viewBranch} onClose={() => setViewBranch(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="branch-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" }}>
|
||||
<div style={{ 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"
|
||||
<div dir="rtl" style={{ maxWidth: 320 }}>
|
||||
<Input
|
||||
label="بحث"
|
||||
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)",
|
||||
}}
|
||||
icon={SearchIcon}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -111,7 +62,14 @@ export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) {
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Rendered after the list modal so it stacks visually on top of it
|
||||
(both use the shared Modal's fixed full-screen overlay at the same
|
||||
z-index — later in the DOM wins the paint order). */}
|
||||
{viewBranch && (
|
||||
<ArchivedBranchDetailModal branch={viewBranch} onClose={() => setViewBranch(null)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../../UI";
|
||||
import { Alert, Badge, Button, Modal, Spinner } from "../../UI";
|
||||
import {
|
||||
MAINTENANCE_STATUS_MAP,
|
||||
fmtDate,
|
||||
@@ -33,55 +32,21 @@ function DetailRow({ label, value, mono = false }: { label: string; value: strin
|
||||
}
|
||||
|
||||
export function ArchivedCarMaintenanceDetailPanel({ record, loading, error, onClose }: ArchivedCarMaintenanceDetailPanelProps) {
|
||||
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-maintenance-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",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={record ? record.reason : "عرض السجل"}
|
||||
subtitle="سجل صيانة مؤرشف"
|
||||
onClose={onClose}
|
||||
zIndex={60}
|
||||
size="md"
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<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-maintenance-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
||||
{record ? record.reason : "عرض السجل"}
|
||||
</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">
|
||||
<div 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" />
|
||||
@@ -89,23 +54,19 @@ export function ArchivedCarMaintenanceDetailPanel({ record, loading, error, onCl
|
||||
</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 && error && <Alert type="error" message={error} />}
|
||||
|
||||
{!loading && record && (() => {
|
||||
const status = MAINTENANCE_STATUS_MAP[getMaintenanceStatus(record)];
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1.25rem" }}>
|
||||
{/* Dynamic per-status colors come from MAINTENANCE_STATUS_MAP, which
|
||||
doesn't map cleanly onto the fixed Badge color palette — kept custom. */}
|
||||
<span style={{ borderRadius: "var(--radius-full)", border: `1px solid ${status.border}`, background: status.bg, padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: status.color }}>
|
||||
{status.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>
|
||||
<Badge label="مؤرشف" color="red" />
|
||||
</div>
|
||||
|
||||
{record.car && (
|
||||
@@ -125,14 +86,6 @@ export function ArchivedCarMaintenanceDetailPanel({ record, loading, error, onCl
|
||||
);
|
||||
})()}
|
||||
</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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "../../UI";
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||
import { fmtDateShort, fmtCost } from "@/src/types/carMaintanance";
|
||||
import type { CarMaintenance } from "@/src/types/carMaintanance";
|
||||
|
||||
@@ -15,17 +15,6 @@ const thStyle: React.CSSProperties = {
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
interface ArchivedCarMaintenanceTableProps {
|
||||
records: CarMaintenance[];
|
||||
loading: boolean;
|
||||
@@ -60,11 +49,10 @@ export function ArchivedCarMaintenanceTable({
|
||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="🔧"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."}
|
||||
/>
|
||||
) : (
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{records.map((r, i) => (
|
||||
@@ -114,10 +102,9 @@ export function ArchivedCarMaintenanceTable({
|
||||
{ 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)" }}>
|
||||
<Button key={btn.label} type="button" variant="secondary" size="sm" onClick={btn.action} disabled={btn.disabled}>
|
||||
{btn.label}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert } from "../../UI";
|
||||
import { Alert, Input, Modal } from "../../UI";
|
||||
import { ArchivedCarMaintenanceTable } from "./ArchivedCarMaintananceTable";
|
||||
import { ArchivedCarMaintenanceDetailPanel } from "./ArchivedCarMaintananceDetailpanel";
|
||||
import { useArchivedCarMaintenance } from "@/src/hooks/archive/UseArchivedCarsMaintanance";
|
||||
@@ -25,15 +25,12 @@ export function ArchivedCarsMaintenanceModal({ carId, carLabel, onClose }: Archi
|
||||
} = useArchivedCarMaintenance(carId);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog" aria-modal="true" aria-labelledby="archive-maintenance-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",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={`سجلات الصيانة المؤرشفة${carLabel ? ` — ${carLabel}` : ""} (${total})`}
|
||||
subtitle="الأرشيف"
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
>
|
||||
{viewRecord && (
|
||||
<ArchivedCarMaintenanceDetailPanel
|
||||
@@ -44,62 +41,20 @@ export function ArchivedCarsMaintenanceModal({ carId, carLabel, onClose }: Archi
|
||||
/>
|
||||
)}
|
||||
|
||||
<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",
|
||||
}}
|
||||
>
|
||||
<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-maintenance-modal-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
||||
سجلات الصيانة المؤرشفة{carLabel ? ` — ${carLabel}` : ""}
|
||||
<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>
|
||||
|
||||
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<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
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<Input
|
||||
label="بحث"
|
||||
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)",
|
||||
}}
|
||||
icon={
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -116,7 +71,6 @@ export function ArchivedCarsMaintenanceModal({ carId, carLabel, onClose }: Archi
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
|
||||
import { useState } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../UI";
|
||||
import type { Client } from "@/src/types/client";
|
||||
|
||||
// ── Address count badge ────────────────────────────────────────────────────
|
||||
@@ -38,47 +38,6 @@ function AddressBadge({ count, onClick }: { count: number; onClick: () => void }
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Client detail panel (inline expandable) ────────────────────────────────
|
||||
/**
|
||||
* ClientDetailPanel — shown below the clicked row.
|
||||
@@ -182,55 +141,28 @@ function ClientDetailPanel({
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexShrink: 0 }}>
|
||||
{/* Edit client */}
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
||||
style={{
|
||||
height: 32,
|
||||
padding: "0 0.875rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid #BFDBFE",
|
||||
background: "#EFF6FF",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "#1D4ED8",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
تعديل البيانات
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
{/*
|
||||
* "Addresses" button — KEY FEATURE:
|
||||
* Navigates to /dashboard/clients/[clientId]/addresses
|
||||
*/}
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); onGoAddresses(); }}
|
||||
style={{
|
||||
height: 32,
|
||||
padding: "0 0.875rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "none",
|
||||
background: "var(--color-brand-600)",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "#FFF",
|
||||
cursor: "pointer",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontFamily: "var(--font-sans)",
|
||||
boxShadow: "0 1px 4px rgba(37,99,235,.3)",
|
||||
}}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||
@@ -251,7 +183,7 @@ function ClientDetailPanel({
|
||||
{client.addresses.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -401,31 +333,17 @@ export function ClientTable({
|
||||
</div>
|
||||
) : clients.length === 0 ? (
|
||||
/* Empty state */
|
||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
{search
|
||||
? `لا توجد نتائج لـ "${search}"`
|
||||
: "لا يوجد عملاء لعرضهم."}
|
||||
</p>
|
||||
{!search && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddFirst}
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-brand-600)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
<EmptyState
|
||||
icon="👥"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد عملاء لعرضهم."}
|
||||
action={
|
||||
!search && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||
أضف أول عميل
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
/* Data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
@@ -659,25 +577,16 @@ export function ClientTable({
|
||||
disabled: page === pages,
|
||||
},
|
||||
].map((btn) => (
|
||||
<button
|
||||
<Button
|
||||
key={btn.label}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
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>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../../UI";
|
||||
import { Alert, Badge, Button, Modal, Spinner } from "../../UI";
|
||||
import { useArchivedClient } from "@/src/hooks/archive/useArchiveClient";
|
||||
|
||||
interface ArchivedClientDetailModalProps {
|
||||
@@ -9,7 +8,7 @@ interface ArchivedClientDetailModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ── small helper components ───────────────────────────────────────────────────
|
||||
// ── small helper components (no template exists for a plain label/value row) ──
|
||||
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div style={{
|
||||
@@ -27,23 +26,7 @@ function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Avatar has no equivalent in the shared UI kit — kept custom.
|
||||
function Avatar({ name }: { name: string }) {
|
||||
const initials = name.trim().split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase();
|
||||
return (
|
||||
@@ -60,22 +43,6 @@ function Avatar({ name }: { name: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Small badge for an archived order row's status
|
||||
function OrderStatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, textTransform: "uppercase",
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
color: "var(--color-text-muted)",
|
||||
padding: "0.15rem 0.5rem",
|
||||
}}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── main component ────────────────────────────────────────────────────────────
|
||||
export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientDetailModalProps) {
|
||||
const {
|
||||
@@ -85,72 +52,24 @@ export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientD
|
||||
setOrdersPage,
|
||||
} = useArchivedClient(clientId);
|
||||
|
||||
// close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
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-client-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",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={client?.name ?? "عرض العميل"}
|
||||
subtitle="عميل مؤرشف"
|
||||
onClose={onClose}
|
||||
zIndex={60}
|
||||
size="md"
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<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-client-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
||||
{client?.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)" }}>
|
||||
@@ -160,17 +79,7 @@ export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientD
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{!loading && error && <Alert type="error" message={error} />}
|
||||
|
||||
{/* content */}
|
||||
{!loading && client && (
|
||||
@@ -186,7 +95,7 @@ export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientD
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{client.name}</p>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<StatusBadge active={client.isActive} />
|
||||
<Badge label={client.isActive ? "نشط" : "معطل"} color={client.isActive ? "green" : "red"} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,7 +165,7 @@ export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientD
|
||||
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>
|
||||
{o.orderNumber ?? o.id}
|
||||
</span>
|
||||
<OrderStatusBadge status={o.status} />
|
||||
<Badge label={o.status} color="slate" />
|
||||
<span style={{ color: "var(--color-text-muted)" }}>
|
||||
{new Date(o.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
@@ -270,16 +179,24 @@ export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientD
|
||||
صفحة {ordersPage} من {ordersPages}
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: 6 }}>
|
||||
<button type="button" disabled={ordersPage === 1}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={ordersPage === 1}
|
||||
onClick={() => setOrdersPage(Math.max(1, ordersPage - 1))}
|
||||
style={{ fontSize: 11, padding: "0.25rem 0.625rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", cursor: ordersPage === 1 ? "not-allowed" : "pointer", opacity: ordersPage === 1 ? 0.4 : 1 }}>
|
||||
>
|
||||
السابق
|
||||
</button>
|
||||
<button type="button" disabled={ordersPage === ordersPages}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={ordersPage === ordersPages}
|
||||
onClick={() => setOrdersPage(Math.min(ordersPages, ordersPage + 1))}
|
||||
style={{ fontSize: 11, padding: "0.25rem 0.625rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface-muted)", cursor: ordersPage === ordersPages ? "not-allowed" : "pointer", opacity: ordersPage === ordersPages ? 0.4 : 1 }}>
|
||||
>
|
||||
التالي
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -287,31 +204,6 @@ export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientD
|
||||
)}
|
||||
</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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "../../UI";
|
||||
import { Badge, Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||
import type { ArchivedClient } from "@/src/types/client";
|
||||
|
||||
// ── 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)",
|
||||
@@ -68,11 +48,10 @@ export function ArchivedClientTable({ clients, loading, search, page, pages, onV
|
||||
</div>
|
||||
) : clients.length === 0 ? (
|
||||
/* empty state */
|
||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد عملاء في الأرشيف."}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="👥"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد عملاء في الأرشيف."}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
@@ -89,7 +68,7 @@ export function ArchivedClientTable({ clients, loading, search, page, pages, onV
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{c.phone}</p>
|
||||
</div>
|
||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{c.email}</span>
|
||||
<StatusBadge active={c.isActive} />
|
||||
<Badge label={c.isActive ? "نشط" : "معطل"} color={c.isActive ? "green" : "red"} />
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(c.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
@@ -121,10 +100,9 @@ export function ArchivedClientTable({ clients, loading, search, page, pages, onV
|
||||
{ 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)" }}>
|
||||
<Button key={btn.label} type="button" variant="secondary" size="sm" onClick={btn.action} disabled={btn.disabled}>
|
||||
{btn.label}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Input, Button } from "../../UI";
|
||||
import { Alert, Input, Modal } from "../../UI";
|
||||
import { ArchivedClientTable } from "./ArchivedClientTable";
|
||||
import { ArchivedClientDetailModal } from "./ArchivedClientDetailModal";
|
||||
import { useArchivedClients } from "@/src/hooks/archive/useArchivedClients";
|
||||
@@ -20,62 +20,18 @@ export function ArchivedClientsModal({ onClose }: ArchivedClientsModalProps) {
|
||||
} = useArchivedClients();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog" aria-modal="true" aria-labelledby="archive-client-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",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={`العملاء المؤرشفون (${total})`}
|
||||
subtitle="الأرشيف"
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
>
|
||||
{viewClientId && (
|
||||
<ArchivedClientDetailModal clientId={viewClientId} onClose={() => setViewClientId(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-client-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"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
aria-label="إغلاق"
|
||||
style={{ width: 34, height: 34, padding: 0, fontSize: 18, lineHeight: 1 }}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{/* search */}
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<Input
|
||||
@@ -105,7 +61,6 @@ export function ArchivedClientsModal({ onClose }: ArchivedClientsModalProps) {
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Button, Modal } from "../../UI";
|
||||
import type { ArchivedClientAddress } from "@/src/types/client_adresses";
|
||||
|
||||
interface ArchivedClientAddressesDetailModalProps {
|
||||
@@ -8,7 +8,7 @@ interface ArchivedClientAddressesDetailModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ── small helper components ───────────────────────────────────────────────────
|
||||
// ── small helper components (no template exists for a plain label/value row) ──
|
||||
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
@@ -42,69 +42,23 @@ function SectionHeader({ title }: { title: string }) {
|
||||
|
||||
// ── main component ────────────────────────────────────────────────────────────
|
||||
export function ArchivedClientAddressesDetailModal({ address, onClose }: ArchivedClientAddressesDetailModalProps) {
|
||||
// close on Escape
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
const { details, contactPerson, location } = address;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog" aria-modal="true" aria-labelledby="archived-address-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",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={address.label}
|
||||
subtitle="عنوان مؤرشف"
|
||||
onClose={onClose}
|
||||
zIndex={60}
|
||||
size="md"
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<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-address-detail-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
||||
{address.label}
|
||||
</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">
|
||||
<div dir="rtl">
|
||||
<DetailRow label="معرف العميل" value={address.clientId} />
|
||||
<DetailRow label="الفرع" value={address.branchName} />
|
||||
|
||||
@@ -143,30 +97,6 @@ export function ArchivedClientAddressesDetailModal({ address, onClose }: Archive
|
||||
<DetailRow label="تاريخ الحذف" value={new Date(address.deletedAt).toLocaleString("ar-SA")} />
|
||||
)}
|
||||
</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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "../../UI";
|
||||
import { Badge, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||
import type { ArchivedClientAddress } from "@/src/types/client_adresses";
|
||||
|
||||
// ── validation badge ─────────────────────────────────────────────────────────
|
||||
function ValidatedBadge({ validated }: { validated: boolean }) {
|
||||
return (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 5, borderRadius: "var(--radius-full)", border: validated ? "1px solid #BBF7D0" : "1px solid var(--color-border)", background: validated ? "#DCFCE7" : "var(--color-surface-muted)", padding: "0.2rem 0.625rem", fontSize: 11, fontWeight: 600, color: validated ? "#166534" : "var(--color-text-muted)" }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: validated ? "#16A34A" : "var(--color-text-hint)" }} />
|
||||
{validated ? "موثّق" : "غير موثّق"}
|
||||
</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)",
|
||||
@@ -65,11 +45,10 @@ export function ArchivedClientAddressesTable({ addresses, loading, search, onVie
|
||||
</div>
|
||||
) : addresses.length === 0 ? (
|
||||
/* empty state */
|
||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="📍"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
@@ -93,7 +72,10 @@ export function ArchivedClientAddressesTable({ addresses, loading, search, onVie
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||
{a.contactPerson?.name ?? "—"}
|
||||
</span>
|
||||
<ValidatedBadge validated={!!a.isValidated} />
|
||||
<Badge
|
||||
label={a.isValidated ? "موثّق" : "غير موثّق"}
|
||||
color={a.isValidated ? "green" : "slate"}
|
||||
/>
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Input, Button } from "../../UI";
|
||||
import { Alert, Input, Modal } from "../../UI";
|
||||
import { ArchivedClientAddressesTable } from "./ArchivedClientAddressesTable";
|
||||
import { ArchivedClientAddressesDetailModal } from "./ArchivedClientAddressesDetailModal";
|
||||
import { useArchivedClientAddresses } from "@/src/hooks/archive/useArchiveClientAdresses";
|
||||
@@ -24,62 +24,18 @@ export function ArchivedClientsAddressesModal({ onClose, clientId }: ArchivedCli
|
||||
} = useArchivedClientAddresses(clientId);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog" aria-modal="true" aria-labelledby="archive-address-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",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={`العناوين المؤرشفة (${total})`}
|
||||
subtitle="الأرشيف"
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
>
|
||||
{viewAddress && (
|
||||
<ArchivedClientAddressesDetailModal address={viewAddress} onClose={() => setViewAddress(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-address-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"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
aria-label="إغلاق"
|
||||
style={{ width: 34, height: 34, padding: 0, fontSize: 18, lineHeight: 1 }}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{/* search — client-side only: this endpoint has no server-side search/pagination */}
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<Input
|
||||
@@ -106,7 +62,6 @@ export function ArchivedClientsAddressesModal({ onClose, clientId }: ArchivedCli
|
||||
onView={setViewAddress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import { Alert, Button, Spinner } from "../UI";
|
||||
import { driverService } from "@/src/services/driver.service";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { DriverReportPanel } from "../Driver_Report/driverReport";
|
||||
@@ -306,18 +306,7 @@ export function DriverDetailPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
borderRadius: "var(--radius-md)",
|
||||
background: "#FEF2F2",
|
||||
border: "1px solid #FECACA",
|
||||
padding: "0.75rem 1rem",
|
||||
fontSize: 13,
|
||||
color: "#DC2626",
|
||||
}}>
|
||||
⚠ {error}
|
||||
</div>
|
||||
)}
|
||||
{error && <Alert type="error" message={error} />}
|
||||
|
||||
{driver && !loading && (
|
||||
<>
|
||||
@@ -462,55 +451,21 @@ export function DriverDetailPanel({
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(driver)}
|
||||
style={{
|
||||
height: 40, padding: "0 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid #FECACA",
|
||||
background: "#FEF2F2",
|
||||
fontSize: 13, fontWeight: 700, color: "#DC2626",
|
||||
cursor: "pointer", fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
<Button type="button" variant="danger" onClick={() => onDelete(driver)}>
|
||||
حذف
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(driver)}
|
||||
style={{
|
||||
height: 40, padding: "0 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-brand-200)",
|
||||
background: "var(--color-brand-50, #EFF6FF)",
|
||||
fontSize: 13, fontWeight: 700, color: "var(--color-brand-600)",
|
||||
cursor: "pointer", fontFamily: "var(--font-sans)",
|
||||
display: "flex", alignItems: "center", gap: 6,
|
||||
}}
|
||||
>
|
||||
<Button type="button" variant="secondary" onClick={() => onEdit(driver)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
تعديل
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
flex: 1, height: 40,
|
||||
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 type="button" variant="secondary" fullWidth onClick={onClose}>
|
||||
إغلاق
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
@@ -86,36 +86,26 @@ export function DriverFormModal({
|
||||
}: DriverFormModalProps) {
|
||||
const isNew = editDriver === null;
|
||||
|
||||
// ── Local branches (auto-fetched if prop is empty) ─────────────────────────
|
||||
const [branches, setBranches] = useState<Branch[]>(branchesProp);
|
||||
useEffect(() => {
|
||||
if (branchesProp.length > 0) {
|
||||
queueMicrotask(() => setBranches(branchesProp));
|
||||
return;
|
||||
}
|
||||
const token = getStoredToken();
|
||||
get<{ data: { data: Branch[] } }>("branches?limit=100", token)
|
||||
.then((res) => {
|
||||
const list = (res as unknown as { data: { data: Branch[] } }).data?.data ?? [];
|
||||
setBranches(list);
|
||||
})
|
||||
.catch(() => {
|
||||
/* silently ignore */
|
||||
});
|
||||
}, [branchesProp]);
|
||||
|
||||
// ── react-hook-form ────────────────────────────────────────────────────────
|
||||
// Moved above the branches-loading effect below: that effect calls
|
||||
// setValue(), so useForm() (which defines it) must run first — otherwise
|
||||
// setValue is referenced before its own initialization (TDZ ReferenceError),
|
||||
// same issue already fixed in TripFormModal.
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
setError,
|
||||
setFocus,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<DriverFormValues>({
|
||||
// Cast the schema itself — the create/update schemas have structurally
|
||||
// different required fields, so yup can't unify them into one type.
|
||||
resolver: yupResolver((isNew ? createDriverSchema : updateDriverSchema) as any),
|
||||
// Cast both the schema argument and the resolver's result — same fix
|
||||
// already applied in TripFormModal: createDriverSchema/updateDriverSchema
|
||||
// have structurally different required fields (union type yupResolver's
|
||||
// single-schema signature won't accept), and yup's inferred type for
|
||||
// .optional() fields is structurally incompatible with DriverFormValues.
|
||||
resolver: yupResolver((isNew ? createDriverSchema : updateDriverSchema) as any) as any,
|
||||
defaultValues: {
|
||||
name: editDriver?.name ?? "",
|
||||
phone: editDriver?.phone ?? "",
|
||||
@@ -142,6 +132,33 @@ export function DriverFormModal({
|
||||
},
|
||||
});
|
||||
|
||||
// ── Local branches (auto-fetched if prop is empty) ─────────────────────────
|
||||
const [branches, setBranches] = useState<Branch[]>(branchesProp);
|
||||
useEffect(() => {
|
||||
// defaultValues.branchId was applied before this list existed, so the
|
||||
// <select> had no matching <option> yet and silently fell back to "" —
|
||||
// reapply the saved id now that the option actually exists in the DOM.
|
||||
const savedBranchId = (editDriver as Driver & { branchId?: string })?.branchId;
|
||||
|
||||
if (branchesProp.length > 0) {
|
||||
queueMicrotask(() => {
|
||||
setBranches(branchesProp);
|
||||
if (savedBranchId) setValue("branchId", savedBranchId);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const token = getStoredToken();
|
||||
get<{ data: { data: Branch[] } }>("branches?limit=100", token)
|
||||
.then((res) => {
|
||||
const list = (res as unknown as { data: { data: Branch[] } }).data?.data ?? [];
|
||||
setBranches(list);
|
||||
if (savedBranchId) setValue("branchId", savedBranchId);
|
||||
})
|
||||
.catch(() => {
|
||||
/* silently ignore */
|
||||
});
|
||||
}, [branchesProp, editDriver, setValue]);
|
||||
|
||||
const [apiError, setApiError] = useState("");
|
||||
|
||||
// register("name") already attaches its own ref — focusing via setFocus
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Spinner } from "../../UI";
|
||||
import { Button, Modal, Spinner, Toast } from "../../UI";
|
||||
import { PhotoCard } from "../DriverPhotos";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { archivedDriverService } from "@/src/services/archive/archivedDriver.service";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
NATIONAL_ID_TYPE_MAP,
|
||||
} from "@/src/types/driver";
|
||||
import type { ArchivedDriver, DriverStatusHistoryEntry } from "@/src/types/driver";
|
||||
import type { ToastNotification } from "@/src/Components/UI";
|
||||
|
||||
interface ArchivedDriverDetailModalProps {
|
||||
driverId: string;
|
||||
@@ -18,6 +19,7 @@ interface ArchivedDriverDetailModalProps {
|
||||
}
|
||||
|
||||
// ── small helper components ───────────────────────────────────────────────────
|
||||
// (No shared-UI equivalents — purpose-built layouts, not generic controls.)
|
||||
|
||||
/** Renders a single label/value row inside the detail body. */
|
||||
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||
@@ -55,44 +57,35 @@ function Avatar({ name }: { name: string }) {
|
||||
|
||||
/**
|
||||
* Modal showing the full profile of a single archived driver, including
|
||||
* documents/photos and status history. Fetches `getById` and
|
||||
* `getStatusHistory` in parallel on mount.
|
||||
* documents/photos and status history. Fetches `getById` on mount — the
|
||||
* archived detail response includes status history embedded.
|
||||
*/
|
||||
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);
|
||||
const [notification, setNotification] = useState<ToastNotification | 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
|
||||
// fetch archived driver details on mount — status history comes embedded
|
||||
// in the same response, no separate call needed
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const [driverData, historyList] = await Promise.all([
|
||||
archivedDriverService.getByIdUnwrapped(driverId, token),
|
||||
archivedDriverService.getStatusHistoryUnwrapped(driverId, token),
|
||||
]);
|
||||
const driverData = await archivedDriverService.getByIdUnwrapped(driverId, token);
|
||||
if (!cancelled) {
|
||||
setDriver(driverData);
|
||||
setHistory(historyList ?? []);
|
||||
setHistory(driverData.statusHistory ?? []);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setError("تعذّر تحميل بيانات السائق المؤرشف. يرجى المحاولة لاحقاً.");
|
||||
if (!cancelled) setNotification({ type: "error", message: "تعذّر تحميل بيانات السائق المؤرشف. يرجى المحاولة لاحقاً." });
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [driverId]);
|
||||
}, [driverId]);
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
const fmt = (iso?: string | null) =>
|
||||
@@ -102,60 +95,19 @@ export function ArchivedDriverDetailModal({ driverId, onClose }: ArchivedDriverD
|
||||
|
||||
// ── 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",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={driver?.name ?? "عرض السائق"}
|
||||
subtitle="سائق مؤرشف"
|
||||
onClose={onClose}
|
||||
zIndex={60}
|
||||
size="lg"
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<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)" }}>
|
||||
@@ -165,17 +117,7 @@ export function ArchivedDriverDetailModal({ driverId, onClose }: ArchivedDriverD
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
{!loading && <Toast notification={notification} onDismiss={() => setNotification(null)} />}
|
||||
|
||||
{/* content */}
|
||||
{!loading && driver && (
|
||||
@@ -271,31 +213,6 @@ export function ArchivedDriverDetailModal({ driverId, onClose }: ArchivedDriverD
|
||||
)}
|
||||
</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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "../../UI";
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||
import { DRIVER_STATUS_MAP } from "@/src/types/driver";
|
||||
import type { ArchivedDriver } from "@/src/types/driver";
|
||||
|
||||
// ── status badge ─────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Badge/>: colors come dynamically from
|
||||
// DRIVER_STATUS_MAP (multiple statuses, each with its own color+dot), which
|
||||
// the shared Badge component's fixed palette doesn't cover.
|
||||
function StatusBadge({ status }: { status: ArchivedDriver["status"] }) {
|
||||
const cfg = DRIVER_STATUS_MAP[status] ?? DRIVER_STATUS_MAP.Inactive;
|
||||
return (
|
||||
@@ -15,16 +18,6 @@ function StatusBadge({ status }: { status: ArchivedDriver["status"] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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)",
|
||||
@@ -75,11 +68,10 @@ export function ArchivedDriverTable({ drivers, loading, search, page, pages, onV
|
||||
</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>
|
||||
<EmptyState
|
||||
icon="🗄️"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد سائقون في الأرشيف."}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
@@ -124,15 +116,24 @@ export function ArchivedDriverTable({ drivers, loading, search, page, pages, onV
|
||||
صفحة <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>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Input, Modal } from "../../UI";
|
||||
import { Input, Modal, Toast } from "../../UI";
|
||||
import { ArchivedDriverTable } from "./ArchivedDriverTable";
|
||||
import { ArchivedDriverDetailModal } from "./ArchivedDriverDetailModal";
|
||||
import { useArchivedDrivers } from "@/src/hooks/archive/useArchivedDrivers";
|
||||
@@ -34,9 +34,9 @@ export function ArchivedDrivers({ onClose }: ArchivedDriversProps) {
|
||||
const [viewDriverId, setViewDriverId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
drivers, loading, total, pages, error,
|
||||
drivers, loading, total, pages, notification,
|
||||
page, search,
|
||||
setPage, handleSearch, clearError,
|
||||
setPage, handleSearch, clearNotification,
|
||||
} = useArchivedDrivers();
|
||||
|
||||
return (
|
||||
@@ -65,7 +65,7 @@ export function ArchivedDrivers({ onClose }: ArchivedDriversProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <Alert type="error" message={error} onClose={clearError} />}
|
||||
<Toast notification={notification} onDismiss={clearNotification} />
|
||||
|
||||
<ArchivedDriverTable
|
||||
drivers={drivers}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Spinner, Alert } from "../UI";
|
||||
import { Alert, Button, Input, Select, Spinner } from "../UI";
|
||||
import { orderService } from "@/src/services/order.service";
|
||||
import type { Order, OrderStatus, UpdateOrderStatusPayload } from "@/src/types/order";
|
||||
|
||||
@@ -277,18 +277,7 @@ export function OrderDetailPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
borderRadius: "var(--radius-md)",
|
||||
background: "#FEF2F2",
|
||||
border: "1px solid #FECACA",
|
||||
padding: "0.75rem 1rem",
|
||||
fontSize: 13,
|
||||
color: "#DC2626",
|
||||
}}>
|
||||
⚠ {error}
|
||||
</div>
|
||||
)}
|
||||
{error && <Alert type="error" message={error} />}
|
||||
|
||||
{order && !loading && (
|
||||
<>
|
||||
@@ -388,57 +377,31 @@ export function OrderDetailPanel({
|
||||
<Alert type="error" message={statusError} onClose={() => setStatusError(null)} />
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
|
||||
<select
|
||||
<Select
|
||||
value={statusDraft}
|
||||
onChange={(e) => setStatusDraft(e.target.value as OrderStatus)}
|
||||
dir="rtl"
|
||||
style={{
|
||||
height: 40, padding: "0 0.75rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13, color: "var(--color-text-primary)",
|
||||
fontFamily: "var(--font-sans)", cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{Object.entries(ORDER_STATUS_MAP).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
</Select>
|
||||
<Input
|
||||
label="سبب التغيير"
|
||||
type="text"
|
||||
value={statusReason}
|
||||
onChange={(e) => setStatusReason(e.target.value)}
|
||||
placeholder="سبب التغيير (اختياري)"
|
||||
dir="rtl"
|
||||
style={{
|
||||
height: 40, padding: "0 0.75rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13, color: "var(--color-text-primary)",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleStatusUpdate}
|
||||
disabled={updatingStatus || !statusDraft || statusDraft === order.currentStatus}
|
||||
style={{
|
||||
height: 40,
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "none",
|
||||
background: updatingStatus ? "var(--color-brand-400)" : "var(--color-brand-600)",
|
||||
fontSize: 13, fontWeight: 700, color: "#FFF",
|
||||
cursor: (updatingStatus || !statusDraft || statusDraft === order.currentStatus) ? "not-allowed" : "pointer",
|
||||
opacity: (!statusDraft || statusDraft === order.currentStatus) ? 0.6 : 1,
|
||||
display: "flex", alignItems: "center", justifyContent: "center", gap: 8,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
disabled={!statusDraft || statusDraft === order.currentStatus}
|
||||
loading={updatingStatus}
|
||||
>
|
||||
{updatingStatus && <Spinner size="sm" className="text-white" />}
|
||||
{updatingStatus ? "جارٍ التحديث…" : "تحديث الحالة"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{order.statusHistory && order.statusHistory.length > 0 && (
|
||||
@@ -492,55 +455,21 @@ export function OrderDetailPanel({
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(order)}
|
||||
style={{
|
||||
height: 40, padding: "0 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid #FECACA",
|
||||
background: "#FEF2F2",
|
||||
fontSize: 13, fontWeight: 700, color: "#DC2626",
|
||||
cursor: "pointer", fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
<Button type="button" variant="danger" onClick={() => onDelete(order)}>
|
||||
حذف
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onEdit(order)}
|
||||
style={{
|
||||
height: 40, padding: "0 1rem",
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-brand-200)",
|
||||
background: "var(--color-brand-50, #EFF6FF)",
|
||||
fontSize: 13, fontWeight: 700, color: "var(--color-brand-600)",
|
||||
cursor: "pointer", fontFamily: "var(--font-sans)",
|
||||
display: "flex", alignItems: "center", gap: 6,
|
||||
}}
|
||||
>
|
||||
<Button type="button" variant="secondary" onClick={() => onEdit(order)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
تعديل
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
flex: 1, height: 40,
|
||||
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 type="button" variant="secondary" fullWidth onClick={onClose}>
|
||||
إغلاق
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
@@ -200,14 +200,6 @@ export function OrderFormModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [onClose]);
|
||||
|
||||
const numberField = (
|
||||
field:
|
||||
| "quantity"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Modal } from "../../UI";
|
||||
import { ORDER_STATUS_MAP } from "../OrderDetailBanel";
|
||||
import type { ArchivedOrder } from "@/src/types/order";
|
||||
|
||||
@@ -34,25 +35,19 @@ export function ArchivedOrderDetailModal({ order, onClose }: ArchivedOrderDetail
|
||||
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">
|
||||
<Modal
|
||||
open
|
||||
title={order.shipmentNumber}
|
||||
subtitle="طلب مؤرشف"
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div 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}
|
||||
@@ -80,14 +75,6 @@ export function ArchivedOrderDetailModal({ order, onClose }: ArchivedOrderDetail
|
||||
</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>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner } from "../../UI";
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||
import { ORDER_STATUS_MAP } from "../OrderDetailBanel";
|
||||
import type { ArchivedOrder } from "@/src/types/order";
|
||||
|
||||
@@ -51,11 +51,10 @@ export function ArchivedOrderTable({ orders, loading, search, page, pages, onVie
|
||||
<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>
|
||||
<EmptyState
|
||||
icon="🗄️"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد طلبات في الأرشيف."}
|
||||
/>
|
||||
) : (
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{orders.map((o, i) => {
|
||||
@@ -88,14 +87,12 @@ export function ArchivedOrderTable({ orders, loading, search, page, pages, onVie
|
||||
{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" }}>
|
||||
<IconBtn title={`عرض ${o.shipmentNumber}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(o)}>
|
||||
<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>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
@@ -109,15 +106,24 @@ export function ArchivedOrderTable({ orders, loading, search, page, pages, onVie
|
||||
صفحة <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>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
155
src/Components/Trip/TripDetailModal.tsx
Normal file
155
src/Components/Trip/TripDetailModal.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
// src/Components/Trip/TripDetailModal.tsx
|
||||
// New — split out of the old single-file Trip module, mirroring
|
||||
// UserDetailModal.tsx / ArchivedDriverDetailModal.tsx for consistency.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Modal, Spinner } from "../UI";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { tripService } from "@/src/services/trip.service";
|
||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||
import type { Trip } from "@/src/types/trip";
|
||||
|
||||
interface TripDetailModalProps {
|
||||
tripId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ── small helper components ───────────────────────────────────────────────────
|
||||
// (No shared-UI equivalents — purpose-built layouts, not generic controls.)
|
||||
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({ status }: { status: Trip["status"] }) {
|
||||
const s = TRIP_STATUS_MAP[status];
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: `1px solid ${s.border}`,
|
||||
background: s.bg,
|
||||
padding: "0.25rem 0.75rem",
|
||||
fontSize: 12, fontWeight: 600,
|
||||
color: s.color,
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── main component ────────────────────────────────────────────────────────────
|
||||
export function TripDetailModal({ tripId, onClose }: TripDetailModalProps) {
|
||||
const [trip, setTrip] = useState<Trip | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// fetch trip details on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const data = await tripService.getById(tripId, token);
|
||||
if (!cancelled) setTrip(data);
|
||||
} catch {
|
||||
if (!cancelled) setError("تعذّر تحميل بيانات الرحلة. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [tripId]);
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
const fmt = (iso?: string | null) =>
|
||||
iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" }) : null;
|
||||
|
||||
const cash = (v?: number | string | null) =>
|
||||
v != null ? `${Number(v).toLocaleString("ar-SA")} ر.س` : null;
|
||||
|
||||
// ── render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={trip?.title ?? "عرض الرحلة"}
|
||||
subtitle="تفاصيل الرحلة"
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{/* 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 && <Alert type="error" message={error} />}
|
||||
|
||||
{/* content */}
|
||||
{!loading && trip && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
|
||||
|
||||
{/* title + trip number + status row */}
|
||||
<div style={{
|
||||
display: "flex", flexDirection: "column", gap: 8,
|
||||
padding: "0 0 1.25rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
marginBottom: "0.25rem",
|
||||
}}>
|
||||
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{trip.title}</p>
|
||||
<p style={{ margin: 0, fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
|
||||
{trip.tripNumber}
|
||||
</p>
|
||||
<StatusBadge status={trip.status} />
|
||||
</div>
|
||||
|
||||
{/* driver / car / branch */}
|
||||
<DetailRow label="السائق" value={trip.driver?.name} />
|
||||
<DetailRow label="السيارة" value={trip.car ? `${trip.car.manufacturer} ${trip.car.model} — ${trip.car.plateNumber}` : null} />
|
||||
<DetailRow label="الفرع" value={trip.branch?.name} />
|
||||
|
||||
{/* timing */}
|
||||
<DetailRow label="وقت البدء" value={fmt(trip.startTime)} />
|
||||
<DetailRow label="وقت الانتهاء" value={fmt(trip.endTime)} />
|
||||
|
||||
{/* counts + cash */}
|
||||
<DetailRow label="المجمّع" value={String(trip.collectedCount)} />
|
||||
<DetailRow label="المُسلَّم" value={String(trip.deliveredCount)} />
|
||||
<DetailRow label="المُرتجع" value={String(trip.returnedCount)} />
|
||||
<DetailRow label="النقد المحصّل" value={cash(trip.totalCashCollected)} />
|
||||
|
||||
{/* notes */}
|
||||
<DetailRow label="ملاحظات" value={trip.notes} />
|
||||
<DetailRow label="سبب الإنهاء" value={trip.endReason} />
|
||||
|
||||
{/* timestamps */}
|
||||
<DetailRow label="تاريخ الإنشاء" value={fmt(trip.createdAt)} />
|
||||
<DetailRow label="آخر تحديث" value={fmt(trip.updatedAt)} />
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
172
src/Components/Trip/TripTable.tsx
Normal file
172
src/Components/Trip/TripTable.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
"use client";
|
||||
|
||||
// src/Components/Trip/TripTable.tsx
|
||||
// New — split out of the old single-file Trip module, mirroring
|
||||
// UserTable.tsx for consistency.
|
||||
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../UI";
|
||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||
import type { Trip } from "@/src/types/trip";
|
||||
|
||||
// ── status badge ─────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Badge/>: colors come dynamically from
|
||||
// TRIP_STATUS_MAP (per-status color+dot), which Badge's fixed palette
|
||||
// doesn't cover — mirrors DriverTable's StatusBadge.
|
||||
function StatusBadge({ status }: { status: Trip["status"] }) {
|
||||
const s = TRIP_STATUS_MAP[status];
|
||||
return (
|
||||
<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 }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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.3fr 1.3fr 1fr 1fr 1fr 100px";
|
||||
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────────
|
||||
interface TripTableProps {
|
||||
trips: Trip[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onEdit: (trip: Trip) => void;
|
||||
onDelete: (trip: Trip) => void;
|
||||
onView: (trip: Trip) => void;
|
||||
onAddFirst: () => void;
|
||||
onPageChange: (p: number) => void;
|
||||
}
|
||||
|
||||
// ── main table ───────────────────────────────────────────────────────────────
|
||||
export function TripTable({ trips, loading, search, page, pages, onEdit, onDelete, onView, onAddFirst, onPageChange }: TripTableProps) {
|
||||
return (
|
||||
<div style={cardStyle}>
|
||||
{/* column headers */}
|
||||
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: ROW_GRID_COLUMNS, ...thStyle }}>
|
||||
<span>الرحلة</span>
|
||||
<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>
|
||||
) : trips.length === 0 ? (
|
||||
/* empty state */
|
||||
<EmptyState
|
||||
icon="🚚"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد رحلات لعرضها."}
|
||||
action={
|
||||
!search && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||
أضف أول رحلة
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{trips.map((t, i) => (
|
||||
<li key={t.id} 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,
|
||||
}}>
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{t.title}</p>
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{t.tripNumber}</p>
|
||||
</div>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{t.driver?.name ?? "—"}</span>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{t.car ? `${t.car.manufacturer} ${t.car.model}` : "—"}</span>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{t.branch?.name ?? "—"}</span>
|
||||
<StatusBadge status={t.status} />
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{fmtDate(t.startTime)}
|
||||
</span>
|
||||
<div style={{ display: "flex", justifyContent: "center", gap: 4 }}>
|
||||
{/* view */}
|
||||
<IconBtn title={`عرض ${t.title}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(t)}>
|
||||
<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>
|
||||
{/* edit */}
|
||||
<IconBtn title={`تعديل ${t.title}`} color="#1D4ED8" bg="#EFF6FF" borderColor="#BFDBFE" onClick={() => onEdit(t)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
{/* delete */}
|
||||
<IconBtn title={`حذف ${t.title}`} color="#DC2626" bg="#FEF2F2" borderColor="#FECACA" onClick={() => onDelete(t)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
|
||||
</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" }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
disabled={page === pages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
"use client";
|
||||
|
||||
// src/Components/Trip/TripFormModal.tsx
|
||||
// CHANGE: renamed from Tripformmodal.tsx (proper PascalCase, matches
|
||||
// UserFormModal.tsx / DriverFormModal.tsx). Split out of the old single-file
|
||||
// Trip module into TripFormModal.tsx / TripTable.tsx / TripDetailModal.tsx.
|
||||
// FIX 1: notes/endReason schema — empty string now transforms to undefined
|
||||
// before .min() runs, so leaving them blank on create no longer fails
|
||||
// validation despite .optional() (see trip.validator.ts).
|
||||
// FIX 2: edit-mode defaultValues now fall back to the nested driver/car/branch
|
||||
// ids, and setValue() re-applies the saved id once each dropdown's options
|
||||
// actually load — previously the <select> had no matching <option> yet when
|
||||
// defaultValues were first applied, so the old selection silently reset to "".
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { Alert, Button, Input, Select, Textarea, Spinner } from "../UI";
|
||||
import { Alert, Button, Input, Modal, Select, Textarea } from "../UI";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import {
|
||||
createTripSchema,
|
||||
@@ -21,6 +33,8 @@ import type { DriverOption } from "@/src/types/driver";
|
||||
import type { CarOption } from "@/src/types/car";
|
||||
import type { BranchOption } from "@/src/types/branch";
|
||||
|
||||
const FORM_ID = "trip-form";
|
||||
|
||||
// ── Shared styles ────────────────────────────────────────────────────────────
|
||||
// Only for section headings — everything field-level now comes from the
|
||||
// Input / Select / Textarea components themselves.
|
||||
@@ -82,29 +96,34 @@ export function TripFormModal({
|
||||
const [cars, setCars] = useState<CarOption[]>([]);
|
||||
const [branches, setBranches] = useState<BranchOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
driverService.getActiveOptions(token).then(setDrivers).catch(() => {});
|
||||
carService.getActiveOptions(token).then(setCars).catch(() => {});
|
||||
branchService.getOptions(token).then(setBranches).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ── react-hook-form ────────────────────────────────────────────────────────
|
||||
// Moved above the dropdown-loading effect below: that effect calls
|
||||
// setValue(), so useForm() (which defines it) must run first — otherwise
|
||||
// setValue is referenced before its own initialization (TDZ ReferenceError).
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
setFocus,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TripFormValues>({
|
||||
// Cast the schema itself — create/update schemas differ structurally in
|
||||
// which fields are required, so yup can't unify them into one type.
|
||||
resolver: yupResolver<TripFormValues>((isNew ? createTripSchema : updateTripSchema) as any),
|
||||
// Cast both the schema argument and the resolver's result:
|
||||
// - argument: createTripSchema/updateTripSchema have structurally
|
||||
// different shapes (create requires title/driverId/etc., update makes
|
||||
// everything optional), so the ternary's type is a union yupResolver's
|
||||
// single-schema signature won't accept.
|
||||
// - result: yup's inferred type for .optional() fields
|
||||
// (`collectedCount?: number`) is structurally incompatible with
|
||||
// TripFormValues's `collectedCount: number | undefined` — letting
|
||||
// useForm<TripFormValues> alone establish the field-values type avoids
|
||||
// that mismatch.
|
||||
resolver: yupResolver((isNew ? createTripSchema : updateTripSchema) as any) as any,
|
||||
defaultValues: {
|
||||
title: editTrip?.title ?? "",
|
||||
driverId: editTrip?.driverId ?? "",
|
||||
carId: editTrip?.carId ?? "",
|
||||
branchId: editTrip?.branchId ?? "",
|
||||
driverId: editTrip?.driverId ?? editTrip?.driver?.id ?? "",
|
||||
carId: editTrip?.carId ?? editTrip?.car?.id ?? "",
|
||||
branchId: editTrip?.branchId ?? editTrip?.branch?.id ?? "",
|
||||
status: editTrip?.status ?? "Scheduled",
|
||||
startTime: editTrip?.startTime?.slice(0, 16) ?? "",
|
||||
endTime: editTrip?.endTime?.slice(0, 16) ?? "",
|
||||
@@ -119,6 +138,31 @@ export function TripFormModal({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
|
||||
driverService.getActiveOptions(token).then((list) => {
|
||||
setDrivers(list);
|
||||
// defaultValues were applied before this list existed, so the <select>
|
||||
// had no matching <option> yet and silently fell back to "" — reapply
|
||||
// the saved id now that the option actually exists in the DOM.
|
||||
const savedDriverId = editTrip?.driverId ?? editTrip?.driver?.id;
|
||||
if (savedDriverId) setValue("driverId", savedDriverId);
|
||||
}).catch(() => {});
|
||||
|
||||
carService.getActiveOptions(token).then((list) => {
|
||||
setCars(list);
|
||||
const savedCarId = editTrip?.carId ?? editTrip?.car?.id;
|
||||
if (savedCarId) setValue("carId", savedCarId);
|
||||
}).catch(() => {});
|
||||
|
||||
branchService.getOptions(token).then((list) => {
|
||||
setBranches(list);
|
||||
const savedBranchId = editTrip?.branchId ?? editTrip?.branch?.id;
|
||||
if (savedBranchId) setValue("branchId", savedBranchId);
|
||||
}).catch(() => {});
|
||||
}, [editTrip, setValue]);
|
||||
|
||||
const [apiError, setApiError] = useState("");
|
||||
|
||||
// register("title") already attaches its own ref — Input forwards it
|
||||
@@ -127,13 +171,6 @@ export function TripFormModal({
|
||||
useEffect(() => {
|
||||
setFocus("title");
|
||||
}, [setFocus]);
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [onClose]);
|
||||
|
||||
const numberField = (
|
||||
field: "collectedCount" | "deliveredCount" | "returnedCount" | "totalCashCollected",
|
||||
@@ -189,85 +226,28 @@ export function TripFormModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="trip-form-title"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
background: "rgba(15,23,42,0.55)",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
padding: "2rem 1rem",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 700,
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "0 24px 56px rgba(0,0,0,.2)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "1.25rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
id="trip-form-title"
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{isNew ? "إضافة رحلة جديدة" : "تعديل بيانات الرحلة"}
|
||||
</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
aria-label="إغلاق"
|
||||
className="!px-1"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
title={isNew ? "إضافة رحلة جديدة" : "تعديل بيانات الرحلة"}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||
إلغاء
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<Button type="submit" form={FORM_ID} variant="primary" loading={isSubmitting}>
|
||||
{isNew ? "إضافة الرحلة" : "حفظ التغييرات"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id={FORM_ID}
|
||||
onSubmit={handleSubmit(submitHandler)}
|
||||
noValidate
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
}}
|
||||
dir="rtl"
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{errors.title?.type === "manual" && (
|
||||
<Alert type="error" message={errors.title.message ?? ""} onClose={() => setApiError("")} />
|
||||
@@ -444,18 +424,7 @@ export function TripFormModal({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
|
||||
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||
إلغاء
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" loading={isSubmitting}>
|
||||
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة الرحلة" : "حفظ التغييرات"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner, Alert, EmptyState, Input, Button } 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 ────────────────
|
||||
// (per-status colors come from TRIP_STATUS_MAP, so the shared Badge component
|
||||
// — which only supports a fixed palette — doesn't cover this; kept custom.)
|
||||
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" });
|
||||
}
|
||||
|
||||
const searchIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* 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="w-full sm:w-72">
|
||||
<Input
|
||||
label="بحث"
|
||||
placeholder="بحث برقم الرحلة أو العنوان..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
icon={searchIcon}
|
||||
dir="rtl"
|
||||
/>
|
||||
</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
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={page === pages}
|
||||
onClick={() => setPage(Math.min(pages, page + 1))}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
src/Components/Trip/archive/ArchivedTripModal.tsx
Normal file
87
src/Components/Trip/archive/ArchivedTripModal.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
// src/Components/Trip/archive/ArchivedTripModal.tsx
|
||||
// Split out of the old ArchivedTripList.tsx — this is the top-level modal
|
||||
// wrapper (search bar + ArchivedTripTable + opens ArchivedTripDetailModal),
|
||||
// mirroring ArchivedDrivers.tsx / ArchivedUsersModal.tsx.
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Input, Modal } from "@/src/Components/UI";
|
||||
import { ArchivedTripTable } from "./ArchivedTripTable";
|
||||
import { ArchivedTripDetailModal } from "./ArchivedtripDetailModal";
|
||||
import { useArchivedTrips } from "@/src/hooks/archive/useArchivedTrips";
|
||||
|
||||
interface ArchivedTripModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const searchIcon = (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
/**
|
||||
* Top-level modal for browsing archived trips. Wires the useArchivedTrips
|
||||
* hook to the table and detail modal, mirroring ArchivedDrivers/
|
||||
* ArchivedUsersModal's structure and behavior.
|
||||
*/
|
||||
export function ArchivedTripModal({ onClose }: ArchivedTripModalProps) {
|
||||
const [viewTripId, setViewTripId] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
trips, loading, total, pages, error,
|
||||
page, search,
|
||||
setPage, handleSearch, clearError,
|
||||
} = useArchivedTrips();
|
||||
|
||||
return (
|
||||
<>
|
||||
{viewTripId && (
|
||||
<ArchivedTripDetailModal tripId={viewTripId} onClose={() => setViewTripId(null)} />
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
subtitle="الأرشيف"
|
||||
title={`الرحلات المؤرشفة (${total})`}
|
||||
>
|
||||
<div dir="rtl" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<Input
|
||||
label="بحث"
|
||||
placeholder="بحث برقم الرحلة أو العنوان..."
|
||||
value={search}
|
||||
onChange={e => handleSearch(e.target.value)}
|
||||
icon={searchIcon}
|
||||
dir="rtl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <Alert type="error" message={error} onClose={clearError} />}
|
||||
|
||||
<ArchivedTripTable
|
||||
trips={trips}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onView={trip => setViewTripId(trip.id)}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
135
src/Components/Trip/archive/ArchivedTripTable.tsx
Normal file
135
src/Components/Trip/archive/ArchivedTripTable.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
// src/Components/Trip/archive/ArchivedTripTable.tsx
|
||||
// Split out of the old ArchivedTripList.tsx — this is just the table portion
|
||||
// (headers, rows, pagination), mirroring ArchivedDriverTable.tsx. Styling is
|
||||
// preserved exactly (Tailwind classes) as it was in the original file.
|
||||
|
||||
import { Spinner, EmptyState, Button } from "@/src/Components/UI";
|
||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||
import type { Trip } from "@/src/types/trip";
|
||||
|
||||
// ── 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" });
|
||||
}
|
||||
|
||||
interface ArchivedTripTableProps {
|
||||
trips: Trip[];
|
||||
loading: boolean;
|
||||
search: string;
|
||||
page: number;
|
||||
pages: number;
|
||||
onView: (trip: Trip) => void;
|
||||
onPageChange: (p: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* ArchivedTripTable
|
||||
* Renders the paginated, searchable table of archived trips, including
|
||||
* loading, empty, and data-row states. Mirrors ArchivedDriverTable.tsx.
|
||||
*/
|
||||
export function ArchivedTripTable({ trips, loading, search, page, pages, onView, onPageChange }: ArchivedTripTableProps) {
|
||||
return (
|
||||
<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)] 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
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={page === 1}
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={page === pages}
|
||||
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
src/Components/Trip/archive/ArchivedtripDetailModal.tsx
Normal file
155
src/Components/Trip/archive/ArchivedtripDetailModal.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
// src/Components/Trip/archive/ArchivedTripDetailModal.tsx
|
||||
// New — mirrors ArchivedDriverDetailModal.tsx / ArchivedUserDetailModal.tsx.
|
||||
// zIndex={60} because this always opens nested inside ArchivedTripModal.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Button, Modal, Spinner } from "@/src/Components/UI";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import { archivedTripService } from "@/src/services/archive/archivedTrip.service";
|
||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
||||
import type { Trip } from "@/src/types/trip";
|
||||
|
||||
interface ArchivedTripDetailModalProps {
|
||||
tripId: 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({ status }: { status: Trip["status"] }) {
|
||||
const s = TRIP_STATUS_MAP[status];
|
||||
return (
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center", gap: 6,
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: `1px solid ${s.border}`,
|
||||
background: s.bg,
|
||||
padding: "0.25rem 0.75rem",
|
||||
fontSize: 12, fontWeight: 600,
|
||||
color: s.color,
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot }} />
|
||||
{s.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── main component ────────────────────────────────────────────────────────────
|
||||
export function ArchivedTripDetailModal({ tripId, onClose }: ArchivedTripDetailModalProps) {
|
||||
const [trip, setTrip] = useState<Trip | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// fetch archived trip details on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const data = await archivedTripService.getByIdUnwrapped(tripId, token);
|
||||
if (!cancelled) setTrip(data);
|
||||
} catch {
|
||||
if (!cancelled) setError("تعذّر تحميل بيانات الرحلة المؤرشفة. يرجى المحاولة لاحقاً.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [tripId]);
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
const fmt = (iso?: string | null) =>
|
||||
iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" }) : null;
|
||||
|
||||
const cash = (v?: number | string | null) =>
|
||||
v != null ? `${Number(v).toLocaleString("ar-SA")} ر.س` : null;
|
||||
|
||||
// ── render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
title={trip?.title ?? "عرض الرحلة"}
|
||||
subtitle="رحلة مؤرشفة"
|
||||
onClose={onClose}
|
||||
zIndex={60}
|
||||
size="md"
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{/* 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 && <Alert type="error" message={error} />}
|
||||
|
||||
{/* content */}
|
||||
{!loading && trip && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
|
||||
|
||||
{/* title + trip number + status row */}
|
||||
<div style={{
|
||||
display: "flex", flexDirection: "column", gap: 8,
|
||||
padding: "0 0 1.25rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
marginBottom: "0.25rem",
|
||||
}}>
|
||||
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{trip.title}</p>
|
||||
<p style={{ margin: 0, fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>
|
||||
{trip.tripNumber}
|
||||
</p>
|
||||
<StatusBadge status={trip.status} />
|
||||
</div>
|
||||
|
||||
{/* driver / car / branch */}
|
||||
<DetailRow label="السائق" value={trip.driver?.name} />
|
||||
<DetailRow label="السيارة" value={trip.car ? `${trip.car.manufacturer} ${trip.car.model} — ${trip.car.plateNumber}` : null} />
|
||||
<DetailRow label="الفرع" value={trip.branch?.name} />
|
||||
|
||||
{/* timing */}
|
||||
<DetailRow label="وقت البدء" value={fmt(trip.startTime)} />
|
||||
<DetailRow label="وقت الانتهاء" value={fmt(trip.endTime)} />
|
||||
|
||||
{/* counts + cash */}
|
||||
<DetailRow label="المجمّع" value={String(trip.collectedCount)} />
|
||||
<DetailRow label="المُسلَّم" value={String(trip.deliveredCount)} />
|
||||
<DetailRow label="المُرتجع" value={String(trip.returnedCount)} />
|
||||
<DetailRow label="النقد المحصّل" value={cash(trip.totalCashCollected)} />
|
||||
|
||||
{/* notes */}
|
||||
<DetailRow label="ملاحظات" value={trip.notes} />
|
||||
<DetailRow label="سبب الإنهاء" value={trip.endReason} />
|
||||
|
||||
{/* timestamps */}
|
||||
<DetailRow label="تاريخ الإنشاء" value={fmt(trip.createdAt)} />
|
||||
<DetailRow label="آخر تحديث" value={fmt(trip.updatedAt)} />
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
// src/Components/Trip/index.ts
|
||||
// CHANGE: removed TripDeleteModal export (Issue 1).
|
||||
export { TripFormModal } from "./Tripformmodal";
|
||||
// CHANGE: split the old single-file Trip module into TripTable.tsx /
|
||||
// TripFormModal.tsx / TripDetailModal.tsx, mirroring src/Components/User.
|
||||
export { TripTable } from "./TripTable";
|
||||
export { TripFormModal } from "./TripFormModal";
|
||||
export { TripDetailModal } from "./TripDetailModal";
|
||||
22
src/Components/UI/IconBtn.tsx
Normal file
22
src/Components/UI/IconBtn.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
// ── Icon button ───────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Button/>: Button's variants only cover
|
||||
// primary/secondary/ghost/danger (one accent color each) and its size scale
|
||||
// (h-8/h-10/h-11 with horizontal padding) isn't built for a fixed 32×32
|
||||
// square icon chip. Forcing it here would misshape the button and collapse
|
||||
// the view/edit/delete green/blue/red color-coding into one variant.
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ interface ModalProps {
|
||||
subtitle?: string;
|
||||
/** ARIA role for the dialog panel — "alertdialog" for destructive/confirm flows */
|
||||
role?: "dialog" | "alertdialog";
|
||||
/** Stacking order — bump this for a modal nested inside another modal (default 50) */
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
const MODAL_SIZES: Record<NonNullable<ModalProps["size"]>, string> = {
|
||||
@@ -33,6 +35,7 @@ export function Modal({
|
||||
size = "md",
|
||||
subtitle,
|
||||
role = "dialog",
|
||||
zIndex = 50,
|
||||
}: ModalProps) {
|
||||
// Escape key to close
|
||||
useEffect(() => {
|
||||
@@ -47,7 +50,7 @@ export function Modal({
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4" style={{ zIndex }}>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-[var(--color-slate-900)]/50 backdrop-blur-sm"
|
||||
|
||||
@@ -25,3 +25,4 @@ export { Badge } from "./Badge";
|
||||
|
||||
export { ArchiveButton } from "./ArchiveButton"
|
||||
export { FileInput } from "./FailInput"
|
||||
export {IconBtn} from "./IconBtn"
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import type { Resolver } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
@@ -29,13 +30,19 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
||||
const resolver: Resolver<UserFormData> = async (values, context, options) => {
|
||||
const schema = isNew ? createUserSchema : updateUserSchema;
|
||||
const data = !isNew && !values.password ? { ...values, password: undefined } : values;
|
||||
return yupResolver<UserFormData>(schema as any)(data as UserFormData, context, options);
|
||||
// Cast both the schema argument and yupResolver's own return value — same
|
||||
// fix already applied in TripFormModal/DriverFormModal/CarFormModal:
|
||||
// passing an explicit <UserFormData> generic here forces a structural
|
||||
// comparison between yup's inferred optional-field shape and
|
||||
// UserFormData that fails the same way it did for those forms.
|
||||
return (yupResolver(schema as any) as any)(data as UserFormData, context, options);
|
||||
};
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<UserFormData>({
|
||||
resolver,
|
||||
@@ -49,6 +56,26 @@ export function UserFormModal({ editUser, roles, branches, onClose, onSubmit }:
|
||||
},
|
||||
});
|
||||
|
||||
// roles/branches are passed in as props rather than fetched inside this
|
||||
// modal (unlike Trip/Driver/Car), but the same timing issue applies if the
|
||||
// parent still has them loading when the modal first mounts: defaultValues
|
||||
// are applied before the matching <option> exists, so the <select>
|
||||
// silently falls back to "". Reapply the saved id once each list actually
|
||||
// contains it.
|
||||
useEffect(() => {
|
||||
const savedRoleId = editUser?.role?.id;
|
||||
if (savedRoleId && roles.some(r => r.id === savedRoleId)) {
|
||||
setValue("roleId", savedRoleId);
|
||||
}
|
||||
}, [roles, editUser, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const savedBranchId = editUser?.branch?.id;
|
||||
if (savedBranchId && branches.some(b => b.id === savedBranchId)) {
|
||||
setValue("branchId", savedBranchId);
|
||||
}
|
||||
}, [branches, editUser, setValue]);
|
||||
|
||||
const submitHandler = async (data: UserFormData) => {
|
||||
const payload: Partial<UserFormData> = { ...data };
|
||||
if (!isNew && !payload.password) delete payload.password;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Badge, Button, EmptyState, Spinner } from "../UI";
|
||||
import { Badge, Button, EmptyState, IconBtn, Spinner } from "../UI";
|
||||
import type { User } from "@/src/types/user";
|
||||
|
||||
// ── status badge ─────────────────────────────────────────────────────────────
|
||||
@@ -15,21 +15,6 @@ function StatusBadge({ active }: { active: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── icon button ──────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Button/>: Button's variants only cover
|
||||
// primary/secondary/ghost/danger (one accent color each) and its size scale
|
||||
// (h-8/h-10/h-11 with horizontal padding) isn't built for a fixed 32×32
|
||||
// square icon chip. Forcing it here would collapse the view/edit/delete
|
||||
// green/blue/red color-coding into a single variant and misshape the 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)",
|
||||
|
||||
@@ -102,6 +102,7 @@ export function ArchivedUserDetailModal({ userId, onClose }: ArchivedUserDetailM
|
||||
subtitle="مستخدم مؤرشف"
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
zIndex={60}
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button, EmptyState, Spinner } from "../../UI";
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||
import type { ArchivedUser } from "@/src/types/user";
|
||||
|
||||
// ── status badge ─────────────────────────────────────────────────────────────
|
||||
@@ -15,21 +15,6 @@ function StatusBadge({ active }: { active: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── icon button ──────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Button/>: Button's variants only cover
|
||||
// primary/secondary/ghost/danger (one accent color each) and its size scale
|
||||
// (h-8/h-10/h-11 with horizontal padding) isn't built for a fixed 32×32
|
||||
// square icon chip. Forcing it here would misshape the button and drop the
|
||||
// semantic green "view" color.
|
||||
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)",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Spinner } from "../UI";
|
||||
import { Alert, Badge, Button, IconBtn, Spinner } from "../UI";
|
||||
import { CarImageGallery } from "./CarImageGallery";
|
||||
import { useCarDetail } from "@/src/hooks/useCars";
|
||||
import { STATUS_MAP, INS_MAP, fmtDate, isExpiringSoon } from "@/src/types/car";
|
||||
@@ -17,7 +17,7 @@ interface CarDetailPanelProps {
|
||||
onDelete: (car: Car) => void;
|
||||
}
|
||||
|
||||
// ── Row component ─────────────────────────────────────────────────────────────
|
||||
// ── Row component (no template exists for a plain label/value row) ────────────
|
||||
|
||||
function DetailRow({ label, value, mono = false, warn = false }: {
|
||||
label: string; value: string; mono?: boolean; warn?: boolean;
|
||||
@@ -65,7 +65,9 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
{/* Panel — a side drawer, not a centered dialog, so it's kept as a
|
||||
custom <aside> rather than the shared <Modal/> (which is
|
||||
centered-only and has no drawer variant). */}
|
||||
<aside
|
||||
aria-label="تفاصيل المركبة"
|
||||
style={{
|
||||
@@ -96,10 +98,15 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
|
||||
</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", flexShrink: 0 }}>
|
||||
×
|
||||
</button>
|
||||
<IconBtn
|
||||
title="إغلاق"
|
||||
color="var(--color-text-muted)"
|
||||
bg="var(--color-surface)"
|
||||
borderColor="var(--color-border)"
|
||||
onClick={onClose}
|
||||
>
|
||||
<span style={{ fontSize: 18, lineHeight: 1 }}>×</span>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,16 +119,14 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={{ borderRadius: "var(--radius-md)", background: "#FEF2F2", border: "1px solid #FECACA", padding: "0.75rem 1rem", fontSize: 13, color: "#DC2626" }}>
|
||||
⚠ {error}
|
||||
</div>
|
||||
)}
|
||||
{error && <Alert type="error" message={error} />}
|
||||
|
||||
{car && !loading && (
|
||||
<>
|
||||
{/* Status badges */}
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1.25rem" }}>
|
||||
{/* Dynamic per-status colors come from STATUS_MAP / INS_MAP, which
|
||||
don't map cleanly onto the fixed Badge color palette — kept custom. */}
|
||||
{(() => {
|
||||
const s = STATUS_MAP[car.currentStatus];
|
||||
return (
|
||||
@@ -138,11 +143,7 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{!car.isActive && (
|
||||
<span style={{ borderRadius: "var(--radius-full)", border: "1px solid #FECACA", background: "#FEF2F2", padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: "#DC2626" }}>
|
||||
محذوف
|
||||
</span>
|
||||
)}
|
||||
{!car.isActive && <Badge label="محذوف" color="red" />}
|
||||
</div>
|
||||
|
||||
{/* Section: Basic Info */}
|
||||
@@ -227,38 +228,39 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
|
||||
}}>
|
||||
{/* Row 1: secondary views */}
|
||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||
<button type="button" onClick={() => setGallery(true)}
|
||||
style={{ flex: 1, height: 40, 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", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
|
||||
<Button type="button" variant="secondary" style={{ flex: 1 }} onClick={() => setGallery(true)}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||
<polyline points="21 15 16 10 5 21" />
|
||||
</svg>
|
||||
الصور
|
||||
</button>
|
||||
<button type="button" onClick={() => router.push(`/dashboard/cars/${car.id}/maintenance`)}
|
||||
style={{ flex: 1, height: 40, 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", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => router.push(`/dashboard/cars/${car.id}/maintenance`)}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
|
||||
</svg>
|
||||
عرض تاريخ الصيانة
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: primary edit / delete */}
|
||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||
<button type="button" onClick={() => onEdit(car)}
|
||||
style={{ flex: 2, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
|
||||
<Button type="button" variant="primary" style={{ flex: 2 }} onClick={() => onEdit(car)}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||
</svg>
|
||||
تعديل
|
||||
</button>
|
||||
<button type="button" onClick={() => onDelete(car)}
|
||||
style={{ height: 40, padding: "0 1rem", borderRadius: "var(--radius-md)", border: "1px solid #FECACA", background: "#FEF2F2", fontSize: 13, fontWeight: 700, color: "#DC2626", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
|
||||
</Button>
|
||||
<Button type="button" variant="danger" onClick={() => onDelete(car)}>
|
||||
حذف
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -89,40 +89,26 @@ export function CarFormModal({
|
||||
}: CarFormModalProps) {
|
||||
const isNew = editCar === null;
|
||||
|
||||
// ── Local branches (auto-fetched if prop is empty) ─────────────────────────
|
||||
const [branches, setBranches] = useState<Branch[]>(branchesProp);
|
||||
const loadBranches = useCallback(() => {
|
||||
if (branchesProp.length > 0) {
|
||||
queueMicrotask(() => setBranches(branchesProp));
|
||||
return;
|
||||
}
|
||||
const token = getStoredToken();
|
||||
branchService
|
||||
.getOptions(token)
|
||||
.then((list) => {
|
||||
queueMicrotask(() => setBranches(list as unknown as Branch[]));
|
||||
})
|
||||
.catch(() => {
|
||||
/* silently ignore */
|
||||
});
|
||||
}, [branchesProp]);
|
||||
|
||||
useEffect(() => {
|
||||
loadBranches();
|
||||
}, [loadBranches]);
|
||||
|
||||
// ── react-hook-form ────────────────────────────────────────────────────────
|
||||
// Moved above the branches-loading effect below: that effect calls
|
||||
// setValue(), so useForm() (which defines it) must run first — otherwise
|
||||
// setValue is referenced before its own initialization (TDZ ReferenceError),
|
||||
// same issue already fixed in TripFormModal / DriverFormModal.
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
setFocus,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<CarFormValues>({
|
||||
// The create/update schemas have structurally different required fields,
|
||||
// so yup's inferred types don't unify — cast the schema itself (not the
|
||||
// yupResolver() call) to sidestep the mismatch.
|
||||
resolver: yupResolver<CarFormValues>((isNew ? createCarSchema : updateCarSchema) as any),
|
||||
// Cast both the schema argument and the resolver's result — same fix
|
||||
// already applied in TripFormModal/DriverFormModal: createCarSchema/
|
||||
// updateCarSchema have structurally different required fields (union
|
||||
// type yupResolver's single-schema signature won't accept), and yup's
|
||||
// inferred type for .optional() fields is structurally incompatible
|
||||
// with CarFormValues.
|
||||
resolver: yupResolver((isNew ? createCarSchema : updateCarSchema) as any) as any,
|
||||
defaultValues: {
|
||||
manufacturer: editCar?.manufacturer ?? "",
|
||||
model: editCar?.model ?? "",
|
||||
@@ -148,6 +134,39 @@ export function CarFormModal({
|
||||
},
|
||||
});
|
||||
|
||||
// ── Local branches (auto-fetched if prop is empty) ─────────────────────────
|
||||
const [branches, setBranches] = useState<Branch[]>(branchesProp);
|
||||
const loadBranches = useCallback(() => {
|
||||
// defaultValues.branchId was applied before this list existed, so the
|
||||
// <select> had no matching <option> yet and silently fell back to "" —
|
||||
// reapply the saved id now that the option actually exists in the DOM.
|
||||
const savedBranchId = editCar?.branch?.id;
|
||||
|
||||
if (branchesProp.length > 0) {
|
||||
queueMicrotask(() => {
|
||||
setBranches(branchesProp);
|
||||
if (savedBranchId) setValue("branchId", savedBranchId);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const token = getStoredToken();
|
||||
branchService
|
||||
.getOptions(token)
|
||||
.then((list) => {
|
||||
queueMicrotask(() => {
|
||||
setBranches(list as unknown as Branch[]);
|
||||
if (savedBranchId) setValue("branchId", savedBranchId);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
/* silently ignore */
|
||||
});
|
||||
}, [branchesProp, editCar, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
loadBranches();
|
||||
}, [loadBranches]);
|
||||
|
||||
const [apiError, setApiError] = useState("");
|
||||
|
||||
// register("manufacturer") already provides a ref for this input, so we
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Spinner } from "../UI";
|
||||
import { Alert, Button, EmptyState, IconBtn, Select, Spinner } from "../UI";
|
||||
import { useCarImages } from "@/src/hooks/useCars";
|
||||
import { STAGE_MAP } from "@/src/types/car";
|
||||
import type { Car, CarImage, ImageStage } from "@/src/types/car";
|
||||
@@ -90,48 +90,61 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
{/* Sort */}
|
||||
<select value={sortBy} onChange={e => setSortBy(e.target.value as "asc" | "desc")}
|
||||
style={{ height: 36, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 12, color: "var(--color-text-secondary)", padding: "0 0.75rem", outline: "none", fontFamily: "var(--font-sans)" }}>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(e.target.value as "asc" | "desc")}
|
||||
wrapperClassName="w-auto"
|
||||
className="h-9"
|
||||
>
|
||||
<option value="desc">الأحدث أولاً</option>
|
||||
<option value="asc">الأقدم أولاً</option>
|
||||
</select>
|
||||
</Select>
|
||||
|
||||
{/* Stage selector for upload */}
|
||||
<select value={stage} onChange={e => setStage(e.target.value as ImageStage)}
|
||||
style={{ height: 36, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 12, color: "var(--color-text-secondary)", padding: "0 0.75rem", outline: "none", fontFamily: "var(--font-sans)" }}>
|
||||
<Select
|
||||
value={stage}
|
||||
onChange={e => setStage(e.target.value as ImageStage)}
|
||||
wrapperClassName="w-auto"
|
||||
className="h-9"
|
||||
>
|
||||
<option value="GENERAL">عام</option>
|
||||
<option value="BEFORE">قبل</option>
|
||||
<option value="AFTER">بعد</option>
|
||||
</select>
|
||||
</Select>
|
||||
|
||||
{/* Upload button */}
|
||||
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploading}
|
||||
style={{ height: 36, padding: "0 1rem", borderRadius: "var(--radius-md)", border: "none", background: uploading ? "var(--color-brand-400)" : "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: uploading ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
|
||||
{uploading
|
||||
? <><Spinner size="sm" className="text-white" /> جارٍ الرفع…</>
|
||||
: <>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
loading={uploading}
|
||||
>
|
||||
{!uploading && (
|
||||
<svg width="14" height="14" 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>
|
||||
)}
|
||||
{uploading ? "جارٍ الرفع…" : "رفع صور"}
|
||||
</Button>
|
||||
<input ref={fileRef} type="file" accept="image/*" multiple style={{ display: "none" }} onChange={handleUpload} />
|
||||
|
||||
{/* Close */}
|
||||
<button type="button" onClick={onClose} aria-label="إغلاق"
|
||||
style={{ width: 36, height: 36, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 20, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
×
|
||||
</button>
|
||||
<IconBtn
|
||||
title="إغلاق"
|
||||
color="var(--color-text-muted)"
|
||||
bg="var(--color-surface)"
|
||||
borderColor="var(--color-border)"
|
||||
onClick={onClose}
|
||||
>
|
||||
<span style={{ fontSize: 20, lineHeight: 1 }}>×</span>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Error ── */}
|
||||
{error && (
|
||||
<div style={{ padding: "0.75rem 1.5rem", background: "#FEF2F2", borderBottom: "1px solid #FECACA", fontSize: 13, color: "#DC2626", fontWeight: 500 }}>
|
||||
⚠ {error}
|
||||
<button onClick={() => setError(null)} style={{ marginRight: 8, fontSize: 14, background: "none", border: "none", color: "#DC2626", cursor: "pointer" }}>×</button>
|
||||
<div style={{ padding: "0.75rem 1.5rem" }}>
|
||||
<Alert type="error" message={error} onClose={() => setError(null)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -143,13 +156,11 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
||||
<span style={{ fontSize: 14, color: "var(--color-text-muted)" }}>جارٍ تحميل الصور…</span>
|
||||
</div>
|
||||
) : images.length === 0 ? (
|
||||
<div style={{ textAlign: "center", padding: "5rem 0" }}>
|
||||
<div style={{ fontSize: 48, marginBottom: 12 }}>📸</div>
|
||||
<p style={{ fontSize: 15, color: "var(--color-text-muted)", fontWeight: 600 }}>لا توجد صور بعد</p>
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-hint)", marginTop: 4 }}>
|
||||
اضغط على "رفع صور" لإضافة أولى الصور لهذه المركبة.
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon="📸"
|
||||
title="لا توجد صور بعد"
|
||||
description='اضغط على "رفع صور" لإضافة أولى الصور لهذه المركبة.'
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: "1rem" }}>
|
||||
{images.map(img => {
|
||||
@@ -204,8 +215,13 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(img.createdAt).toLocaleDateString("ar-SA", { month: "short", day: "numeric" })}
|
||||
</span>
|
||||
<button type="button" onClick={() => handleDelete(img.id)} disabled={isDeleting} aria-label="حذف الصورة"
|
||||
style={{ width: 28, height: 28, borderRadius: "var(--radius-sm)", border: "1px solid #FECACA", background: "#FEF2F2", color: "#DC2626", cursor: isDeleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
<IconBtn
|
||||
title="حذف الصورة"
|
||||
color="#DC2626"
|
||||
bg="#FEF2F2"
|
||||
borderColor="#FECACA"
|
||||
onClick={() => handleDelete(img.id)}
|
||||
>
|
||||
{isDeleting
|
||||
? <Spinner size="sm" className="text-red-600" />
|
||||
: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
@@ -213,7 +229,7 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
||||
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
</IconBtn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -243,6 +259,8 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
||||
onLoad={() => setLightboxLoaded(true)}
|
||||
onError={e => { (e.target as HTMLImageElement).src = "/file.svg"; setLightboxLoaded(true); }}
|
||||
/>
|
||||
{/* Kept custom — a circular translucent button on a dark overlay,
|
||||
a different visual context than IconBtn's light-surface style. */}
|
||||
<button onClick={() => setLightbox(null)} style={{ position: "absolute", top: 24, right: 24, width: 40, height: 40, borderRadius: "50%", background: "rgba(255,255,255,0.15)", border: "none", color: "#fff", fontSize: 20, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { Spinner, Button } from "../../UI";
|
||||
import { Alert, Badge, Button, Modal, Spinner } from "../../UI";
|
||||
import { STATUS_MAP, INS_MAP, fmtDate, isExpiringSoon } from "@/src/types/car";
|
||||
import type { Car, InsuranceStatus } from "@/src/types/car";
|
||||
|
||||
@@ -35,62 +34,21 @@ function DetailRow({ label, value, mono = false, warn = false }: {
|
||||
}
|
||||
|
||||
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"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
aria-label="إغلاق"
|
||||
style={{ width: 34, height: 34, padding: 0, fontSize: 18, lineHeight: 1 }}
|
||||
>
|
||||
×
|
||||
<Modal
|
||||
open
|
||||
title={car ? `${car.manufacturer} ${car.model}` : "عرض المركبة"}
|
||||
subtitle="مركبة مؤرشفة"
|
||||
onClose={onClose}
|
||||
zIndex={60}
|
||||
size="md"
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }} dir="rtl">
|
||||
}
|
||||
>
|
||||
<div 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" />
|
||||
@@ -98,16 +56,14 @@ export function ArchivedCarDetailPanel({ car, loading, error, onClose }: Archive
|
||||
</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 && error && <Alert type="error" message={error} />}
|
||||
|
||||
{!loading && car && (
|
||||
<>
|
||||
{/* status badges */}
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1.25rem" }}>
|
||||
{/* Dynamic per-status colors come from STATUS_MAP / INS_MAP, which
|
||||
don't map cleanly onto the fixed Badge color palette — kept custom. */}
|
||||
{(() => {
|
||||
const s = STATUS_MAP[car.currentStatus];
|
||||
return (
|
||||
@@ -124,9 +80,7 @@ export function ArchivedCarDetailPanel({ car, loading, error, onClose }: Archive
|
||||
</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>
|
||||
<Badge label="مؤرشفة" color="red" />
|
||||
</div>
|
||||
|
||||
<DetailRow label="رقم اللوحة" value={`${car.plateLetters} ${car.plateNumber}`} mono />
|
||||
@@ -140,14 +94,6 @@ export function ArchivedCarDetailPanel({ car, loading, error, onClose }: Archive
|
||||
</>
|
||||
)}
|
||||
</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" variant="secondary" onClick={onClose}>
|
||||
إغلاق
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert, Input, Button } from "../../UI";
|
||||
import { Alert, Input, Modal } from "../../UI";
|
||||
import { ArchivedCarTable } from "./Archivedcartable";
|
||||
import { ArchivedCarDetailPanel } from "./Archivedcardetailpanel";
|
||||
import { useArchivedCars } from "@/src/hooks/archive/Usearchivedcars";
|
||||
@@ -21,15 +21,12 @@ export function ArchivedCarsModal({ onClose }: ArchivedCarsModalProps) {
|
||||
} = 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",
|
||||
}}
|
||||
<Modal
|
||||
open
|
||||
title={`المركبات المؤرشفة (${total})`}
|
||||
subtitle="الأرشيف"
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
>
|
||||
{viewCar && (
|
||||
<ArchivedCarDetailPanel
|
||||
@@ -40,48 +37,7 @@ export function ArchivedCarsModal({ onClose }: ArchivedCarsModalProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<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"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
aria-label="إغلاق"
|
||||
style={{ width: 34, height: 34, padding: 0, fontSize: 18, lineHeight: 1 }}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||
{/* search */}
|
||||
<div style={{ maxWidth: 320 }}>
|
||||
<Input
|
||||
@@ -111,7 +67,6 @@ export function ArchivedCarsModal({ onClose }: ArchivedCarsModalProps) {
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Spinner, Button } from "../../UI";
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||
import { STATUS_MAP, fmtDateShort } from "@/src/types/car";
|
||||
import type { Car } from "@/src/types/car";
|
||||
|
||||
@@ -16,28 +16,6 @@ const thStyle: React.CSSProperties = {
|
||||
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"
|
||||
variant="ghost"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
onClick={e => { e.stopPropagation(); onClick(); }}
|
||||
style={{
|
||||
width: 32, height: 32, padding: 0,
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: `1px solid ${borderColor}`,
|
||||
background: bg,
|
||||
color,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Props ────────────────────────────────────────────────────────────────────
|
||||
interface ArchivedCarTableProps {
|
||||
cars: Car[];
|
||||
@@ -71,11 +49,10 @@ export function ArchivedCarTable({ cars, loading, search, page, pages, onView, o
|
||||
</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>
|
||||
<EmptyState
|
||||
icon="🚗"
|
||||
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد مركبات في الأرشيف."}
|
||||
/>
|
||||
) : (
|
||||
/* data rows */
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Role } from "@/src/types/role";
|
||||
import { Button, EmptyState, Spinner } from "../UI";
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../UI";
|
||||
|
||||
// ── Status badge ──────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
||||
@@ -23,28 +23,7 @@ function StatusBadge({ active }: { active: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Icon button ───────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Button/>: Button's variants only cover
|
||||
// primary/secondary/ghost/danger (one accent color each) and its size scale
|
||||
// (h-8/h-10/h-11 with horizontal padding) isn't built for a fixed 32×32
|
||||
// square icon chip. Forcing it here would misshape the button and collapse
|
||||
// the view/edit/delete green/blue/red color-coding into one variant.
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── Styles ────────────────────────────────────────────────────────────────────
|
||||
const cardStyle: React.CSSProperties = {
|
||||
|
||||
@@ -101,6 +101,7 @@ export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailM
|
||||
title={role?.name ?? "عرض الدور"}
|
||||
subtitle="دور مؤرشف"
|
||||
onClose={onClose}
|
||||
zIndex={60}
|
||||
size="md"
|
||||
footer={
|
||||
<Button type="button" variant="secondary" onClick={onClose}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Button, EmptyState, Spinner } from "../../UI";
|
||||
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||
import type { ArchivedRole } from "@/src/types/role";
|
||||
|
||||
// ── status badge ─────────────────────────────────────────────────────────────
|
||||
@@ -15,19 +15,6 @@ function StatusBadge({ active }: { active: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── icon button ──────────────────────────────────────────────────────────────
|
||||
// Kept custom rather than swapped for <Button/>: Button's variants only cover
|
||||
// primary/secondary/ghost/danger (one accent color each) and its size scale
|
||||
// isn't built for a fixed 32×32 square icon chip.
|
||||
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)",
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
import type { ToastNotification } from "@/src/Components/UI";
|
||||
|
||||
/**
|
||||
* Loads and paginates the archived drivers list, mirroring useArchivedUsers.
|
||||
@@ -14,7 +15,12 @@ export function useArchivedDrivers() {
|
||||
const [pages, setPages] = useState(1);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notification, setNotification] = useState<ToastNotification | null>(null);
|
||||
|
||||
const notify = useCallback((n: ToastNotification) => {
|
||||
setNotification(n);
|
||||
setTimeout(() => setNotification(null), 4000);
|
||||
}, []);
|
||||
|
||||
/** Fetches the current page/search slice of archived drivers from the API. */
|
||||
const load = useCallback(async () => {
|
||||
@@ -25,13 +31,12 @@ export function useArchivedDrivers() {
|
||||
setDrivers(res.data.data);
|
||||
setTotal(res.data.meta.total);
|
||||
setPages(res.data.meta.totalPages);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("تعذّر تحميل قائمة السائقين المؤرشفين. يرجى المحاولة لاحقاً.");
|
||||
notify({ type: "error", message: "تعذّر تحميل قائمة السائقين المؤرشفين. يرجى المحاولة لاحقاً." });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search]);
|
||||
}, [page, search, notify]);
|
||||
|
||||
useEffect(() => { queueMicrotask(load); }, [load]);
|
||||
|
||||
@@ -42,8 +47,9 @@ export function useArchivedDrivers() {
|
||||
};
|
||||
|
||||
return {
|
||||
drivers, loading, total, pages, page, search, error,
|
||||
setPage, handleSearch, clearError: () => setError(null),
|
||||
drivers, loading, total, pages, page, search,
|
||||
notification,
|
||||
setPage, handleSearch, clearNotification: () => setNotification(null),
|
||||
refresh: load,
|
||||
};
|
||||
}
|
||||
@@ -179,6 +179,7 @@ export function useDrivers() {
|
||||
updateDriver,
|
||||
deleteDriver,
|
||||
notification,
|
||||
clearNotification: () => setNotification(null),
|
||||
reload: () => loadDrivers(page, search),
|
||||
};
|
||||
}
|
||||
@@ -90,7 +90,7 @@ useEffect(() => {
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await userService.update(id, data, token);
|
||||
dispatch({ type: "UPDATE", user: res?.data });
|
||||
dispatch({ type: "UPDATE", user: res });
|
||||
notify({ type: "success", message: "تم تحديث بيانات المستخدم بنجاح." });
|
||||
return true;
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { get } from "../api";
|
||||
import type {
|
||||
ArchivedDriverListResponse,
|
||||
ArchivedDriverResponse,
|
||||
ArchivedDriverStatusHistoryResponse,
|
||||
} from "@/src/types/driver";
|
||||
|
||||
/**
|
||||
@@ -36,17 +35,9 @@ export const archivedDriverService = {
|
||||
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),
|
||||
getAllUnwrapped: async (page, search, token) => {
|
||||
getAllUnwrapped: async (page: number, search: string, token: string | null) => {
|
||||
const res = await archivedDriverService.getAll(page, search, token);
|
||||
return { items: res.data.data, total: res.data.meta.total, pages: res.data.meta.totalPages };
|
||||
},
|
||||
getByIdUnwrapped: async (id, token) => (await archivedDriverService.getById(id, token)).data,
|
||||
getStatusHistoryUnwrapped: async (id, token) => (await archivedDriverService.getStatusHistory(id, token)).data ?? [],
|
||||
getByIdUnwrapped: async (id: string, token: string | null) => (await archivedDriverService.getById(id, token)).data,
|
||||
};
|
||||
@@ -161,6 +161,7 @@ export interface ArchivedDriver {
|
||||
driverType?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
statusHistory?: DriverStatusHistoryEntry[];
|
||||
}
|
||||
|
||||
// GET /driver/archived
|
||||
|
||||
@@ -86,11 +86,13 @@ export const createTripSchema = yup.object({
|
||||
|
||||
notes: yup
|
||||
.string()
|
||||
.transform((value) => (value === "" ? undefined : value))
|
||||
.min(2, "الملاحظات يجب أن تكون حرفين على الأقل") // doc
|
||||
.optional(),
|
||||
|
||||
endReason: yup
|
||||
.string()
|
||||
.transform((value) => (value === "" ? undefined : value))
|
||||
.min(2, "سبب الانتهاء يجب أن يكون حرفين على الأقل") // doc
|
||||
.optional(),
|
||||
});
|
||||
@@ -159,11 +161,13 @@ export const updateTripSchema = yup.object({
|
||||
|
||||
notes: yup
|
||||
.string()
|
||||
.transform((value) => (value === "" ? undefined : value))
|
||||
.min(2, "الملاحظات يجب أن تكون حرفين على الأقل")
|
||||
.optional(),
|
||||
|
||||
endReason: yup
|
||||
.string()
|
||||
.transform((value) => (value === "" ? undefined : value))
|
||||
.min(2, "سبب الانتهاء يجب أن يكون حرفين على الأقل")
|
||||
.optional(),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user