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() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
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 permissions = user?.permissions ?? [];
|
||||||
const { open, setOpen } = useSidebarDrawer();
|
const { open, setOpen } = useSidebarDrawer();
|
||||||
|
|
||||||
@@ -363,7 +376,6 @@ function SidebarContent({
|
|||||||
pathname.startsWith(item.href + "/"));
|
pathname.startsWith(item.href + "/"));
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
suppressHydrationWarning
|
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
title={compact ? item.label : undefined}
|
title={compact ? item.label : undefined}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Spinner,
|
Spinner,
|
||||||
ArchiveButton,
|
ArchiveButton,
|
||||||
ConfirmDialog,
|
ConfirmDialog,
|
||||||
|
Toast,
|
||||||
} from "@/src/Components/UI";
|
} from "@/src/Components/UI";
|
||||||
import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers";
|
import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers";
|
||||||
import { useDrivers } from "@/src/hooks/useDriver";
|
import { useDrivers } from "@/src/hooks/useDriver";
|
||||||
@@ -87,6 +88,7 @@ export default function DriversPage() {
|
|||||||
updateDriver,
|
updateDriver,
|
||||||
deleteDriver,
|
deleteDriver,
|
||||||
notification,
|
notification,
|
||||||
|
clearNotification,
|
||||||
} = useDrivers();
|
} = useDrivers();
|
||||||
|
|
||||||
// ── Panel / modal state ───────────────────────────────────────────────────
|
// ── Panel / modal state ───────────────────────────────────────────────────
|
||||||
@@ -174,9 +176,7 @@ export default function DriversPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Notifications ── */}
|
{/* ── Notifications ── */}
|
||||||
{notification && (
|
<Toast notification={notification} onDismiss={clearNotification} />
|
||||||
<Alert type={notification.type} message={notification.message} />
|
|
||||||
)}
|
|
||||||
{error && <Alert type="error" message={error} onClose={clearError} />}
|
{error && <Alert type="error" message={error} onClose={clearError} />}
|
||||||
|
|
||||||
{/* ── Table ── */}
|
{/* ── Table ── */}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
"use client";
|
"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 { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { ConfirmDialog, Spinner } from "@/src/Components/UI";
|
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 { TripReportPanel } from "@/src/Components/Trip_Report/Tripreportpanel";
|
||||||
import { tripService } from "@/src/services/trip.service";
|
import { tripService } from "@/src/services/trip.service";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
@@ -213,7 +222,7 @@ export default function TripDetailPage() {
|
|||||||
const data = await tripService.getById(tripId, token);
|
const data = await tripService.getById(tripId, token);
|
||||||
setTrip(data);
|
setTrip(data);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات الرحلة.");
|
setError(extractApiMessage(err, "تعذّر تحميل بيانات الرحلة."));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +1,28 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { ArchivedTripDetailModal } from "@/src/Components/Trip/archive/ArchivedtripDetailModal";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
// app/dashboard/trips/archived/[tripId]/page.tsx
|
||||||
import { Spinner, EmptyState, Badge, PageHeader } from "@/src/Components/UI";
|
// CHANGE: rebuilt as a thin route wrapper around the shared
|
||||||
import { tripService } from "@/src/services/trip.service";
|
// ArchivedTripDetailModal instead of its own independent fetch/loading/error/
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
// render logic. This route now shows the exact same full detail view as the
|
||||||
import type { Trip } from "@/src/types/trip";
|
// in-app archive browser (driver + car + counts + cash + notes + endReason,
|
||||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
// 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 {
|
import { useParams, useRouter } from "next/navigation";
|
||||||
if (!iso) return "—";
|
|
||||||
return new Date(iso).toLocaleString("ar-SA", { dateStyle: "medium", timeStyle: "short" });
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ArchivedTripDetailPage() {
|
export default function ArchivedTripDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const tripId = params?.tripId as string;
|
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 (
|
return (
|
||||||
<div className="flex items-center justify-center gap-3 py-24 text-[var(--color-text-muted)]">
|
<ArchivedTripDetailModal
|
||||||
<Spinner size="sm" />
|
tripId={tripId}
|
||||||
<span className="text-sm">جارٍ التحميل…</span>
|
onClose={() => router.push("/dashboard/trips")}
|
||||||
</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>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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";
|
"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 { useCallback, useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTrips } from "@/src/hooks/useTrip";
|
import { useTrips } from "@/src/hooks/useTrip";
|
||||||
import { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
|
import { TripFormModal, TripTable } from "@/src/Components/Trip";
|
||||||
import { ArchivedTripList } from "@/src/Components/Trip/archive/ArchivedTripList";
|
import { ArchivedTripModal } from "@/src/Components/Trip/archive/ArchivedTripModal";
|
||||||
import { Alert, Spinner, ArchiveButton, ConfirmDialog , Toast } from "@/src/Components/UI";
|
import { Alert, ArchiveButton, ConfirmDialog, Toast } from "@/src/Components/UI";
|
||||||
import type {
|
import type { Trip, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip";
|
||||||
Trip,
|
|
||||||
TripStatus,
|
|
||||||
CreateTripPayload,
|
|
||||||
UpdateTripPayload,
|
|
||||||
} from "@/src/types/trip";
|
|
||||||
import { TRIP_STATUS_MAP } from "@/src/types/trip";
|
|
||||||
import Header from "@/src/Components/UI/Header";
|
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 ──────────────────────────────────────────────────────────────────────
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function TripsPage() {
|
export default function TripsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// All list state + mutations now come from the hook, same as useDrivers
|
// All list state + mutations come from the hook, same as useDrivers is
|
||||||
// is used on the Driver page. No direct tripService calls in this file.
|
// used on the Driver page. No direct tripService calls in this file.
|
||||||
const {
|
const {
|
||||||
trips,
|
trips,
|
||||||
total,
|
total,
|
||||||
@@ -169,8 +36,6 @@ export default function TripsPage() {
|
|||||||
setPage,
|
setPage,
|
||||||
search,
|
search,
|
||||||
handleSearch,
|
handleSearch,
|
||||||
status,
|
|
||||||
handleStatusFilter,
|
|
||||||
createTrip,
|
createTrip,
|
||||||
updateTrip,
|
updateTrip,
|
||||||
deleteTrip,
|
deleteTrip,
|
||||||
@@ -202,6 +67,19 @@ export default function TripsPage() {
|
|||||||
setDeleteTarget(trip);
|
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) ───────
|
// ── Create / update — delegates to the hook (hook fires the toast) ───────
|
||||||
const handleSubmit = async (
|
const handleSubmit = async (
|
||||||
payload: CreateTripPayload | UpdateTripPayload,
|
payload: CreateTripPayload | UpdateTripPayload,
|
||||||
@@ -233,7 +111,8 @@ export default function TripsPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<Header mainTitle="إدارة الرحلات"
|
<Header
|
||||||
|
mainTitle="إدارة الرحلات"
|
||||||
title="الرحلات"
|
title="الرحلات"
|
||||||
name="رحلة"
|
name="رحلة"
|
||||||
state={{ total }}
|
state={{ total }}
|
||||||
@@ -243,288 +122,27 @@ export default function TripsPage() {
|
|||||||
setModule={() => {}}
|
setModule={() => {}}
|
||||||
setPage={setPage}
|
setPage={setPage}
|
||||||
isAudit={false}
|
isAudit={false}
|
||||||
onAdd={() => { setEditTrip(null); setShowForm(true); }}/>
|
onAdd={handleAdd}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ── Error handling: Alert banner for list/API errors ──
|
{/* ── Error handling: Alert banner for list/API errors ──
|
||||||
Same pattern as the Driver page: a persistent banner tied to the
|
Same pattern as the Driver page: a persistent banner tied to the
|
||||||
hook's `error` state, dismissible via `clearError`. */}
|
hook's `error` state, dismissible via `clearError`. */}
|
||||||
{error && <Alert type="error" message={error} onClose={clearError} />}
|
{error && <Alert type="error" message={error} onClose={clearError} />}
|
||||||
|
|
||||||
{/* ── Table ── */}
|
{/* ── Table ── */}
|
||||||
<div
|
<TripTable
|
||||||
style={{
|
trips={trips}
|
||||||
background: "var(--color-surface)",
|
loading={loading}
|
||||||
border: "1px solid var(--color-border)",
|
search={search}
|
||||||
borderRadius: "var(--radius-xl)",
|
page={page}
|
||||||
overflow: "hidden",
|
pages={pages}
|
||||||
boxShadow: "var(--shadow-card)",
|
onEdit={handleEdit}
|
||||||
}}
|
onDelete={handleDelete}
|
||||||
>
|
onView={handleView}
|
||||||
<div
|
onAddFirst={handleAdd}
|
||||||
dir="rtl"
|
onPageChange={setPage}
|
||||||
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>
|
|
||||||
|
|
||||||
{/* ── Form modal ── */}
|
{/* ── Form modal ── */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
@@ -549,10 +167,10 @@ export default function TripsPage() {
|
|||||||
description={`هل أنت متأكد من حذف رحلة ${deleteTarget?.title ?? ""} (${deleteTarget?.tripNumber ?? ""})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
description={`هل أنت متأكد من حذف رحلة ${deleteTarget?.title ?? ""} (${deleteTarget?.tripNumber ?? ""})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Archive browser modal ── */}
|
{/* ── Archive browser modal — now the split ArchivedTripModal directly,
|
||||||
{archiveOpen && (
|
no local wrapper needed since it already ships its own search bar,
|
||||||
<ArchivedTripsModal onClose={() => setArchiveOpen(false)} />
|
table, and detail-view wiring. ── */}
|
||||||
)}
|
{archiveOpen && <ArchivedTripModal onClose={() => setArchiveOpen(false)} />}
|
||||||
|
|
||||||
{/* ── Floating archive button — same component Driver page uses ── */}
|
{/* ── Floating archive button — same component Driver page uses ── */}
|
||||||
<ArchiveButton onClick={() => setArchiveOpen(true)} label="الأرشيف" />
|
<ArchiveButton onClick={() => setArchiveOpen(true)} label="الأرشيف" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Spinner } from "../UI";
|
import { Alert, Badge, Button, Modal, Spinner } from "../UI";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { branchService } from "@/src/services/branch.service";
|
import { branchService } from "@/src/services/branch.service";
|
||||||
import type { BranchDetail } from "@/src/types/branch";
|
import type { BranchDetail } from "@/src/types/branch";
|
||||||
@@ -11,7 +11,7 @@ interface BranchDetailModalProps {
|
|||||||
onClose: () => void;
|
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 }) {
|
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -29,23 +29,7 @@ function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ active }: { active: boolean }) {
|
// Branch icon badge has no equivalent in the shared UI kit — kept custom.
|
||||||
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() {
|
function BranchIcon() {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -69,13 +53,6 @@ export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps)
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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
|
// fetch branch details on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -102,60 +79,18 @@ export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps)
|
|||||||
|
|
||||||
// ── render ────────────────────────────────────────────────────────────────
|
// ── render ────────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="branch-detail-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
title={branch?.name ?? "عرض الفرع"}
|
||||||
style={{
|
subtitle="بيانات الفرع"
|
||||||
position: "fixed", inset: 0, zIndex: 55,
|
onClose={onClose}
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
size="md"
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
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 */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
<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 */}
|
{/* error */}
|
||||||
{!loading && error && (
|
{!loading && error && <Alert type="error" message={error} />}
|
||||||
<div style={{
|
|
||||||
padding: "1rem 1.25rem",
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
background: "#FEF2F2", border: "1px solid #FECACA",
|
|
||||||
fontSize: 13, color: "#991B1B", fontWeight: 500,
|
|
||||||
textAlign: "center",
|
|
||||||
}}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* content */}
|
{/* content */}
|
||||||
{!loading && branch && (
|
{!loading && branch && (
|
||||||
@@ -197,7 +122,7 @@ export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps)
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<StatusBadge active={branch.isActive} />
|
<Badge label={branch.isActive ? "نشط" : "معطل"} color={branch.isActive ? "green" : "red"} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,31 +144,6 @@ export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps)
|
|||||||
<DetailRow label="آخر تحديث" value={fmt(branch.updatedAt)} />
|
<DetailRow label="آخر تحديث" value={fmt(branch.updatedAt)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Modal>
|
||||||
|
|
||||||
{/* ── 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Spinner } from "../UI";
|
import { Badge, Button, EmptyState, IconBtn, Spinner } from "../UI";
|
||||||
import type { Branch } from "@/src/types/branch";
|
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 ─────────────────────────────────────────────────────
|
// ── card / header styles ─────────────────────────────────────────────────────
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
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>
|
</div>
|
||||||
) : branches.length === 0 ? (
|
) : branches.length === 0 ? (
|
||||||
/* empty state */
|
/* empty state */
|
||||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
<EmptyState
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
icon="🏢"
|
||||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."}
|
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."}
|
||||||
</p>
|
action={
|
||||||
{!search && (
|
!search && (
|
||||||
<button type="button" onClick={onAddFirst}
|
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||||
style={{ marginTop: 12, fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
|
|
||||||
أضف أول فرع
|
أضف أول فرع
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
/* data rows */
|
/* data rows */
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
<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>
|
</div>
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>{b.city || "—"}</span>
|
<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>
|
<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)" }}>
|
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||||
</span>
|
</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.max(1, page - 1)), disabled: page === 1 },
|
||||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
||||||
].map(btn => (
|
].map(btn => (
|
||||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
<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}
|
{btn.label}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { Badge, Button, Modal } from "../../UI";
|
||||||
import type { ArchivedBranch } from "@/src/types/branch";
|
import type { ArchivedBranch } from "@/src/types/branch";
|
||||||
|
|
||||||
interface ArchivedBranchDetailModalProps {
|
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() {
|
function BranchIcon() {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -65,13 +48,6 @@ function BranchIcon() {
|
|||||||
// modal does NOT fetch — it just renders the ArchivedBranch object that was
|
// modal does NOT fetch — it just renders the ArchivedBranch object that was
|
||||||
// already loaded from GET /branches/archived (passed down from the table).
|
// already loaded from GET /branches/archived (passed down from the table).
|
||||||
export function ArchivedBranchDetailModal({ branch, onClose }: ArchivedBranchDetailModalProps) {
|
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 ───────────────────────────────────────────────────────────────
|
// ── helpers ───────────────────────────────────────────────────────────────
|
||||||
const fmt = (iso?: string | null) =>
|
const fmt = (iso?: string | null) =>
|
||||||
iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : 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 ────────────────────────────────────────────────────────────────
|
// ── render ────────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="archived-branch-detail-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
onClose={onClose}
|
||||||
style={{
|
size="md"
|
||||||
position: "fixed", inset: 0, zIndex: 55,
|
subtitle="فرع مؤرشف"
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
title={branch.name}
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
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">
|
<div style={{ display: "flex", flexDirection: "column", gap: 0 }} dir="rtl">
|
||||||
|
|
||||||
{/* icon + name + status row */}
|
{/* icon + name + status row */}
|
||||||
@@ -150,7 +86,7 @@ export function ArchivedBranchDetailModal({ branch, onClose }: ArchivedBranchDet
|
|||||||
#{branch.id}
|
#{branch.id}
|
||||||
</p>
|
</p>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<StatusBadge active={branch.isActive} />
|
<Badge label={branch.isActive ? "نشط" : "معطل"} color={branch.isActive ? "green" : "red"} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,31 +112,6 @@ export function ArchivedBranchDetailModal({ branch, onClose }: ArchivedBranchDet
|
|||||||
<DetailRow label="تاريخ الإنشاء" value={fmt(branch.createdAt)} />
|
<DetailRow label="تاريخ الإنشاء" value={fmt(branch.createdAt)} />
|
||||||
<DetailRow label="آخر تحديث" value={fmt(branch.updatedAt)} />
|
<DetailRow label="آخر تحديث" value={fmt(branch.updatedAt)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
|
|
||||||
{/* ── 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Spinner } from "../../UI";
|
import { Badge, Button, IconBtn, Spinner } from "../../UI";
|
||||||
import type { ArchivedBranch } from "@/src/types/branch";
|
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 ─────────────────────────────────────────────────────
|
// ── card / header styles ─────────────────────────────────────────────────────
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
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>
|
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>{b.street}</p>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>{b.city || "—"}</span>
|
<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)" }}>
|
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||||
</span>
|
</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>
|
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
{[
|
<Button
|
||||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
variant="secondary"
|
||||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
size="sm"
|
||||||
].map(btn => (
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
disabled={page === 1}
|
||||||
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>
|
||||||
))}
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||||
|
disabled={page === pages}
|
||||||
|
>
|
||||||
|
التالي
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert } from "../../UI";
|
import { Alert, Input, Modal } from "../../UI";
|
||||||
import { ArchivedBranchTable } from "./ArchivedBranchTable";
|
import { ArchivedBranchTable } from "./ArchivedBranchTable";
|
||||||
import { ArchivedBranchDetailModal } from "./ArchivedBranchDetailModal";
|
import { ArchivedBranchDetailModal } from "./ArchivedBranchDetailModal";
|
||||||
import { useArchivedBranches } from "@/src/hooks/archive/useArchiveBranch";
|
import { useArchivedBranches } from "@/src/hooks/archive/useArchiveBranch";
|
||||||
@@ -11,6 +11,12 @@ interface ArchivedBranchesModalProps {
|
|||||||
onClose: () => void;
|
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) {
|
export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) {
|
||||||
// hold the full object, not just the id — no GET /branches/archived/{id} route exists
|
// hold the full object, not just the id — no GET /branches/archived/{id} route exists
|
||||||
const [viewBranch, setViewBranch] = useState<ArchivedBranch | null>(null);
|
const [viewBranch, setViewBranch] = useState<ArchivedBranch | null>(null);
|
||||||
@@ -22,79 +28,24 @@ export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) {
|
|||||||
} = useArchivedBranches();
|
} = useArchivedBranches();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<>
|
||||||
role="dialog" aria-modal="true" aria-labelledby="branch-archive-modal-title"
|
<Modal
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
open
|
||||||
style={{
|
onClose={onClose}
|
||||||
position: "fixed", inset: 0, zIndex: 70,
|
size="lg"
|
||||||
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)",
|
subtitle="الأرشيف"
|
||||||
display: "flex", alignItems: "flex-start", justifyContent: "center",
|
title={`الفروع المؤرشفة (${total})`}
|
||||||
padding: "2rem 1rem", overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{viewBranch && (
|
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
<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" }}>
|
|
||||||
{/* search */}
|
{/* search */}
|
||||||
<div dir="rtl" style={{ position: "relative", maxWidth: 320 }}>
|
<div dir="rtl" style={{ maxWidth: 320 }}>
|
||||||
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
|
<Input
|
||||||
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
label="بحث"
|
||||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="بحث بالاسم أو المدينة..."
|
placeholder="بحث بالاسم أو المدينة..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => handleSearch(e.target.value)}
|
onChange={e => handleSearch(e.target.value)}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
style={{
|
icon={SearchIcon}
|
||||||
width: "100%", height: 40,
|
|
||||||
paddingRight: 36, paddingLeft: 12,
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13, outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -111,7 +62,14 @@ export function ArchivedBranchesModal({ onClose }: ArchivedBranchesModalProps) {
|
|||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
|
{/* 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";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { Alert, Badge, Button, Modal, Spinner } from "../../UI";
|
||||||
import { Spinner } from "../../UI";
|
|
||||||
import {
|
import {
|
||||||
MAINTENANCE_STATUS_MAP,
|
MAINTENANCE_STATUS_MAP,
|
||||||
fmtDate,
|
fmtDate,
|
||||||
@@ -33,55 +32,21 @@ function DetailRow({ label, value, mono = false }: { label: string; value: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ArchivedCarMaintenanceDetailPanel({ record, loading, error, onClose }: ArchivedCarMaintenanceDetailPanelProps) {
|
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 (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="archived-maintenance-detail-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
title={record ? record.reason : "عرض السجل"}
|
||||||
style={{
|
subtitle="سجل صيانة مؤرشف"
|
||||||
position: "fixed", inset: 0, zIndex: 80,
|
onClose={onClose}
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
zIndex={60}
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
size="md"
|
||||||
}}
|
footer={
|
||||||
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
|
إغلاق
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div dir="rtl">
|
||||||
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">
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
<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" />
|
<Spinner size="sm" className="text-blue-600" />
|
||||||
@@ -89,23 +54,19 @@ export function ArchivedCarMaintenanceDetailPanel({ record, loading, error, onCl
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && error && (
|
{!loading && error && <Alert type="error" message={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 && record && (() => {
|
{!loading && record && (() => {
|
||||||
const status = MAINTENANCE_STATUS_MAP[getMaintenanceStatus(record)];
|
const status = MAINTENANCE_STATUS_MAP[getMaintenanceStatus(record)];
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1.25rem" }}>
|
<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 }}>
|
<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}
|
{status.label}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ borderRadius: "var(--radius-full)", border: "1px solid #FECACA", background: "#FEF2F2", padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: "#DC2626" }}>
|
<Badge label="مؤرشف" color="red" />
|
||||||
مؤرشف
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{record.car && (
|
{record.car && (
|
||||||
@@ -125,14 +86,6 @@ export function ArchivedCarMaintenanceDetailPanel({ record, loading, error, onCl
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Spinner } from "../../UI";
|
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||||
import { fmtDateShort, fmtCost } from "@/src/types/carMaintanance";
|
import { fmtDateShort, fmtCost } from "@/src/types/carMaintanance";
|
||||||
import type { CarMaintenance } 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)",
|
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 {
|
interface ArchivedCarMaintenanceTableProps {
|
||||||
records: CarMaintenance[];
|
records: CarMaintenance[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -60,11 +49,10 @@ export function ArchivedCarMaintenanceTable({
|
|||||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
||||||
</div>
|
</div>
|
||||||
) : records.length === 0 ? (
|
) : records.length === 0 ? (
|
||||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
<EmptyState
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
icon="🔧"
|
||||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."}
|
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد سجلات صيانة في الأرشيف."}
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||||
{records.map((r, i) => (
|
{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.max(1, page - 1)), disabled: page === 1 },
|
||||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
||||||
].map(btn => (
|
].map(btn => (
|
||||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
<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}
|
{btn.label}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert } from "../../UI";
|
import { Alert, Input, Modal } from "../../UI";
|
||||||
import { ArchivedCarMaintenanceTable } from "./ArchivedCarMaintananceTable";
|
import { ArchivedCarMaintenanceTable } from "./ArchivedCarMaintananceTable";
|
||||||
import { ArchivedCarMaintenanceDetailPanel } from "./ArchivedCarMaintananceDetailpanel";
|
import { ArchivedCarMaintenanceDetailPanel } from "./ArchivedCarMaintananceDetailpanel";
|
||||||
import { useArchivedCarMaintenance } from "@/src/hooks/archive/UseArchivedCarsMaintanance";
|
import { useArchivedCarMaintenance } from "@/src/hooks/archive/UseArchivedCarsMaintanance";
|
||||||
@@ -25,15 +25,12 @@ export function ArchivedCarsMaintenanceModal({ carId, carLabel, onClose }: Archi
|
|||||||
} = useArchivedCarMaintenance(carId);
|
} = useArchivedCarMaintenance(carId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="archive-maintenance-modal-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
title={`سجلات الصيانة المؤرشفة${carLabel ? ` — ${carLabel}` : ""} (${total})`}
|
||||||
style={{
|
subtitle="الأرشيف"
|
||||||
position: "fixed", inset: 0, zIndex: 70,
|
onClose={onClose}
|
||||||
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)",
|
size="lg"
|
||||||
display: "flex", alignItems: "flex-start", justifyContent: "center",
|
|
||||||
padding: "2rem 1rem", overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{viewRecord && (
|
{viewRecord && (
|
||||||
<ArchivedCarMaintenanceDetailPanel
|
<ArchivedCarMaintenanceDetailPanel
|
||||||
@@ -44,62 +41,20 @@ export function ArchivedCarsMaintenanceModal({ carId, carLabel, onClose }: Archi
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
onClick={e => e.stopPropagation()}
|
<div style={{ maxWidth: 320 }}>
|
||||||
style={{
|
<Input
|
||||||
width: "100%", maxWidth: 980,
|
label="بحث"
|
||||||
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
|
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="بحث بسبب الصيانة..."
|
placeholder="بحث بسبب الصيانة..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => handleSearch(e.target.value)}
|
onChange={e => handleSearch(e.target.value)}
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
style={{
|
icon={
|
||||||
width: "100%", height: 40,
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
paddingRight: 36, paddingLeft: 12,
|
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||||
borderRadius: "var(--radius-lg)",
|
</svg>
|
||||||
border: "1px solid var(--color-border)",
|
}
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13, outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -116,7 +71,6 @@ export function ArchivedCarsMaintenanceModal({ carId, carLabel, onClose }: Archi
|
|||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Spinner } from "../UI";
|
import { Button, EmptyState, IconBtn, Spinner } from "../UI";
|
||||||
import type { Client } from "@/src/types/client";
|
import type { Client } from "@/src/types/client";
|
||||||
|
|
||||||
// ── Address count badge ────────────────────────────────────────────────────
|
// ── 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) ────────────────────────────────
|
// ── Client detail panel (inline expandable) ────────────────────────────────
|
||||||
/**
|
/**
|
||||||
* ClientDetailPanel — shown below the clicked row.
|
* ClientDetailPanel — shown below the clicked row.
|
||||||
@@ -182,55 +141,28 @@ function ClientDetailPanel({
|
|||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div style={{ display: "flex", gap: "0.5rem", flexShrink: 0 }}>
|
<div style={{ display: "flex", gap: "0.5rem", flexShrink: 0 }}>
|
||||||
{/* Edit client */}
|
{/* Edit client */}
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
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">
|
<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="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" />
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
تعديل البيانات
|
تعديل البيانات
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
* "Addresses" button — KEY FEATURE:
|
* "Addresses" button — KEY FEATURE:
|
||||||
* Navigates to /dashboard/clients/[clientId]/addresses
|
* Navigates to /dashboard/clients/[clientId]/addresses
|
||||||
*/}
|
*/}
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={(e) => { e.stopPropagation(); onGoAddresses(); }}
|
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">
|
<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" />
|
<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}
|
{client.addresses.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -401,31 +333,17 @@ export function ClientTable({
|
|||||||
</div>
|
</div>
|
||||||
) : clients.length === 0 ? (
|
) : clients.length === 0 ? (
|
||||||
/* Empty state */
|
/* Empty state */
|
||||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
<EmptyState
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
icon="👥"
|
||||||
{search
|
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد عملاء لعرضهم."}
|
||||||
? `لا توجد نتائج لـ "${search}"`
|
action={
|
||||||
: "لا يوجد عملاء لعرضهم."}
|
!search && (
|
||||||
</p>
|
<Button type="button" variant="ghost" size="sm" onClick={onAddFirst}>
|
||||||
{!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",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
أضف أول عميل
|
أضف أول عميل
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
/* Data rows */
|
/* Data rows */
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||||
@@ -659,25 +577,16 @@ export function ClientTable({
|
|||||||
disabled: page === pages,
|
disabled: page === pages,
|
||||||
},
|
},
|
||||||
].map((btn) => (
|
].map((btn) => (
|
||||||
<button
|
<Button
|
||||||
key={btn.label}
|
key={btn.label}
|
||||||
type="button"
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
onClick={btn.action}
|
onClick={btn.action}
|
||||||
disabled={btn.disabled}
|
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}
|
{btn.label}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { Alert, Badge, Button, Modal, Spinner } from "../../UI";
|
||||||
import { Spinner } from "../../UI";
|
|
||||||
import { useArchivedClient } from "@/src/hooks/archive/useArchiveClient";
|
import { useArchivedClient } from "@/src/hooks/archive/useArchiveClient";
|
||||||
|
|
||||||
interface ArchivedClientDetailModalProps {
|
interface ArchivedClientDetailModalProps {
|
||||||
@@ -9,7 +8,7 @@ interface ArchivedClientDetailModalProps {
|
|||||||
onClose: () => void;
|
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 }) {
|
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -27,23 +26,7 @@ function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ active }: { active: boolean }) {
|
// Avatar has no equivalent in the shared UI kit — kept custom.
|
||||||
return (
|
|
||||||
<span style={{
|
|
||||||
display: "inline-flex", alignItems: "center", gap: 6,
|
|
||||||
borderRadius: "var(--radius-full)",
|
|
||||||
border: active ? "1px solid #BBF7D0" : "1px solid #FECACA",
|
|
||||||
background: active ? "#DCFCE7" : "#FEF2F2",
|
|
||||||
padding: "0.25rem 0.75rem",
|
|
||||||
fontSize: 12, fontWeight: 600,
|
|
||||||
color: active ? "#166534" : "#991B1B",
|
|
||||||
}}>
|
|
||||||
<span style={{ width: 6, height: 6, borderRadius: "50%", background: active ? "#16A34A" : "#DC2626" }} />
|
|
||||||
{active ? "نشط" : "معطل"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Avatar({ name }: { name: string }) {
|
function Avatar({ name }: { name: string }) {
|
||||||
const initials = name.trim().split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase();
|
const initials = name.trim().split(" ").slice(0, 2).map(w => w[0]).join("").toUpperCase();
|
||||||
return (
|
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 ────────────────────────────────────────────────────────────
|
// ── main component ────────────────────────────────────────────────────────────
|
||||||
export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientDetailModalProps) {
|
export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientDetailModalProps) {
|
||||||
const {
|
const {
|
||||||
@@ -85,72 +52,24 @@ export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientD
|
|||||||
setOrdersPage,
|
setOrdersPage,
|
||||||
} = useArchivedClient(clientId);
|
} = 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) =>
|
const fmt = (iso?: string | null) =>
|
||||||
iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null;
|
iso ? new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "long", day: "numeric" }) : null;
|
||||||
|
|
||||||
// ── render ────────────────────────────────────────────────────────────────
|
// ── render ────────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="archived-client-detail-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
title={client?.name ?? "عرض العميل"}
|
||||||
style={{
|
subtitle="عميل مؤرشف"
|
||||||
position: "fixed", inset: 0, zIndex: 55,
|
onClose={onClose}
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
zIndex={60}
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
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 */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
<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 */}
|
{/* error */}
|
||||||
{!loading && error && (
|
{!loading && error && <Alert type="error" message={error} />}
|
||||||
<div style={{
|
|
||||||
padding: "1rem 1.25rem",
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
background: "#FEF2F2", border: "1px solid #FECACA",
|
|
||||||
fontSize: 13, color: "#991B1B", fontWeight: 500,
|
|
||||||
textAlign: "center",
|
|
||||||
}}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* content */}
|
{/* content */}
|
||||||
{!loading && client && (
|
{!loading && client && (
|
||||||
@@ -186,7 +95,7 @@ export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientD
|
|||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{client.name}</p>
|
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{client.name}</p>
|
||||||
<div style={{ marginTop: 8 }}>
|
<div style={{ marginTop: 8 }}>
|
||||||
<StatusBadge active={client.isActive} />
|
<Badge label={client.isActive ? "نشط" : "معطل"} color={client.isActive ? "green" : "red"} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -256,7 +165,7 @@ export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientD
|
|||||||
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>
|
<span style={{ fontWeight: 600, color: "var(--color-text-primary)" }}>
|
||||||
{o.orderNumber ?? o.id}
|
{o.orderNumber ?? o.id}
|
||||||
</span>
|
</span>
|
||||||
<OrderStatusBadge status={o.status} />
|
<Badge label={o.status} color="slate" />
|
||||||
<span style={{ color: "var(--color-text-muted)" }}>
|
<span style={{ color: "var(--color-text-muted)" }}>
|
||||||
{new Date(o.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
{new Date(o.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||||
</span>
|
</span>
|
||||||
@@ -270,16 +179,24 @@ export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientD
|
|||||||
صفحة {ordersPage} من {ordersPages}
|
صفحة {ordersPage} من {ordersPages}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: "flex", gap: 6 }}>
|
<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))}
|
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>
|
||||||
<button type="button" disabled={ordersPage === ordersPages}
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={ordersPage === ordersPages}
|
||||||
onClick={() => setOrdersPage(Math.min(ordersPages, ordersPage + 1))}
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -287,31 +204,6 @@ export function ArchivedClientDetailModal({ clientId, onClose }: ArchivedClientD
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Modal>
|
||||||
|
|
||||||
{/* ── 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Spinner } from "../../UI";
|
import { Badge, Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||||
import type { ArchivedClient } from "@/src/types/client";
|
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 ─────────────────────────────────────────────────────
|
// ── card / header styles ─────────────────────────────────────────────────────
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
||||||
@@ -68,11 +48,10 @@ export function ArchivedClientTable({ clients, loading, search, page, pages, onV
|
|||||||
</div>
|
</div>
|
||||||
) : clients.length === 0 ? (
|
) : clients.length === 0 ? (
|
||||||
/* empty state */
|
/* empty state */
|
||||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
<EmptyState
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
icon="👥"
|
||||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد عملاء في الأرشيف."}
|
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد عملاء في الأرشيف."}
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
/* data rows */
|
/* data rows */
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
<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>
|
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{c.phone}</p>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "#2563EB", fontWeight: 600 }}>{c.email}</span>
|
<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)" }}>
|
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
{new Date(c.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
{new Date(c.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||||
</span>
|
</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.max(1, page - 1)), disabled: page === 1 },
|
||||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
||||||
].map(btn => (
|
].map(btn => (
|
||||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
<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}
|
{btn.label}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Input, Button } from "../../UI";
|
import { Alert, Input, Modal } from "../../UI";
|
||||||
import { ArchivedClientTable } from "./ArchivedClientTable";
|
import { ArchivedClientTable } from "./ArchivedClientTable";
|
||||||
import { ArchivedClientDetailModal } from "./ArchivedClientDetailModal";
|
import { ArchivedClientDetailModal } from "./ArchivedClientDetailModal";
|
||||||
import { useArchivedClients } from "@/src/hooks/archive/useArchivedClients";
|
import { useArchivedClients } from "@/src/hooks/archive/useArchivedClients";
|
||||||
@@ -20,62 +20,18 @@ export function ArchivedClientsModal({ onClose }: ArchivedClientsModalProps) {
|
|||||||
} = useArchivedClients();
|
} = useArchivedClients();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="archive-client-modal-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
title={`العملاء المؤرشفون (${total})`}
|
||||||
style={{
|
subtitle="الأرشيف"
|
||||||
position: "fixed", inset: 0, zIndex: 70,
|
onClose={onClose}
|
||||||
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)",
|
size="lg"
|
||||||
display: "flex", alignItems: "flex-start", justifyContent: "center",
|
|
||||||
padding: "2rem 1rem", overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{viewClientId && (
|
{viewClientId && (
|
||||||
<ArchivedClientDetailModal clientId={viewClientId} onClose={() => setViewClientId(null)} />
|
<ArchivedClientDetailModal clientId={viewClientId} onClose={() => setViewClientId(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
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" }}>
|
|
||||||
{/* search */}
|
{/* search */}
|
||||||
<div style={{ maxWidth: 320 }}>
|
<div style={{ maxWidth: 320 }}>
|
||||||
<Input
|
<Input
|
||||||
@@ -105,7 +61,6 @@ export function ArchivedClientsModal({ onClose }: ArchivedClientsModalProps) {
|
|||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { Button, Modal } from "../../UI";
|
||||||
import type { ArchivedClientAddress } from "@/src/types/client_adresses";
|
import type { ArchivedClientAddress } from "@/src/types/client_adresses";
|
||||||
|
|
||||||
interface ArchivedClientAddressesDetailModalProps {
|
interface ArchivedClientAddressesDetailModalProps {
|
||||||
@@ -8,7 +8,7 @@ interface ArchivedClientAddressesDetailModalProps {
|
|||||||
onClose: () => void;
|
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 }) {
|
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
return (
|
return (
|
||||||
@@ -42,69 +42,23 @@ function SectionHeader({ title }: { title: string }) {
|
|||||||
|
|
||||||
// ── main component ────────────────────────────────────────────────────────────
|
// ── main component ────────────────────────────────────────────────────────────
|
||||||
export function ArchivedClientAddressesDetailModal({ address, onClose }: ArchivedClientAddressesDetailModalProps) {
|
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;
|
const { details, contactPerson, location } = address;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="archived-address-detail-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
title={address.label}
|
||||||
style={{
|
subtitle="عنوان مؤرشف"
|
||||||
position: "fixed", inset: 0, zIndex: 80,
|
onClose={onClose}
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
zIndex={60}
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
size="md"
|
||||||
}}
|
footer={
|
||||||
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
|
إغلاق
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
<div dir="rtl">
|
||||||
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">
|
|
||||||
<DetailRow label="معرف العميل" value={address.clientId} />
|
<DetailRow label="معرف العميل" value={address.clientId} />
|
||||||
<DetailRow label="الفرع" value={address.branchName} />
|
<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")} />
|
<DetailRow label="تاريخ الحذف" value={new Date(address.deletedAt).toLocaleString("ar-SA")} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
{/* ── 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Spinner } from "../../UI";
|
import { Badge, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||||
import type { ArchivedClientAddress } from "@/src/types/client_adresses";
|
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 ─────────────────────────────────────────────────────
|
// ── card / header styles ─────────────────────────────────────────────────────
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
||||||
@@ -65,11 +45,10 @@ export function ArchivedClientAddressesTable({ addresses, loading, search, onVie
|
|||||||
</div>
|
</div>
|
||||||
) : addresses.length === 0 ? (
|
) : addresses.length === 0 ? (
|
||||||
/* empty state */
|
/* empty state */
|
||||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
<EmptyState
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
icon="📍"
|
||||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."}
|
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد عناوين في الأرشيف."}
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
/* data rows */
|
/* data rows */
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
<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)" }}>
|
<span style={{ fontSize: 12, color: "var(--color-text-secondary)" }}>
|
||||||
{a.contactPerson?.name ?? "—"}
|
{a.contactPerson?.name ?? "—"}
|
||||||
</span>
|
</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)" }}>
|
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
{new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
{new Date(a.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Input, Button } from "../../UI";
|
import { Alert, Input, Modal } from "../../UI";
|
||||||
import { ArchivedClientAddressesTable } from "./ArchivedClientAddressesTable";
|
import { ArchivedClientAddressesTable } from "./ArchivedClientAddressesTable";
|
||||||
import { ArchivedClientAddressesDetailModal } from "./ArchivedClientAddressesDetailModal";
|
import { ArchivedClientAddressesDetailModal } from "./ArchivedClientAddressesDetailModal";
|
||||||
import { useArchivedClientAddresses } from "@/src/hooks/archive/useArchiveClientAdresses";
|
import { useArchivedClientAddresses } from "@/src/hooks/archive/useArchiveClientAdresses";
|
||||||
@@ -24,62 +24,18 @@ export function ArchivedClientsAddressesModal({ onClose, clientId }: ArchivedCli
|
|||||||
} = useArchivedClientAddresses(clientId);
|
} = useArchivedClientAddresses(clientId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="archive-address-modal-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
title={`العناوين المؤرشفة (${total})`}
|
||||||
style={{
|
subtitle="الأرشيف"
|
||||||
position: "fixed", inset: 0, zIndex: 70,
|
onClose={onClose}
|
||||||
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)",
|
size="lg"
|
||||||
display: "flex", alignItems: "flex-start", justifyContent: "center",
|
|
||||||
padding: "2rem 1rem", overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{viewAddress && (
|
{viewAddress && (
|
||||||
<ArchivedClientAddressesDetailModal address={viewAddress} onClose={() => setViewAddress(null)} />
|
<ArchivedClientAddressesDetailModal address={viewAddress} onClose={() => setViewAddress(null)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
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" }}>
|
|
||||||
{/* search — client-side only: this endpoint has no server-side search/pagination */}
|
{/* search — client-side only: this endpoint has no server-side search/pagination */}
|
||||||
<div style={{ maxWidth: 320 }}>
|
<div style={{ maxWidth: 320 }}>
|
||||||
<Input
|
<Input
|
||||||
@@ -106,7 +62,6 @@ export function ArchivedClientsAddressesModal({ onClose, clientId }: ArchivedCli
|
|||||||
onView={setViewAddress}
|
onView={setViewAddress}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Spinner } from "../UI";
|
import { Alert, Button, Spinner } from "../UI";
|
||||||
import { driverService } from "@/src/services/driver.service";
|
import { driverService } from "@/src/services/driver.service";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { DriverReportPanel } from "../Driver_Report/driverReport";
|
import { DriverReportPanel } from "../Driver_Report/driverReport";
|
||||||
@@ -306,18 +306,7 @@ export function DriverDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && <Alert type="error" message={error} />}
|
||||||
<div style={{
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
background: "#FEF2F2",
|
|
||||||
border: "1px solid #FECACA",
|
|
||||||
padding: "0.75rem 1rem",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "#DC2626",
|
|
||||||
}}>
|
|
||||||
⚠ {error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{driver && !loading && (
|
{driver && !loading && (
|
||||||
<>
|
<>
|
||||||
@@ -462,55 +451,21 @@ export function DriverDetailPanel({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<Button type="button" variant="danger" onClick={() => onDelete(driver)}>
|
||||||
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>
|
</Button>
|
||||||
|
|
||||||
<button
|
<Button type="button" variant="secondary" onClick={() => onEdit(driver)}>
|
||||||
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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<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="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" />
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
تعديل
|
تعديل
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<button
|
<Button type="button" variant="secondary" fullWidth onClick={onClose}>
|
||||||
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -86,36 +86,26 @@ export function DriverFormModal({
|
|||||||
}: DriverFormModalProps) {
|
}: DriverFormModalProps) {
|
||||||
const isNew = editDriver === null;
|
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 ────────────────────────────────────────────────────────
|
// ── 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 {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
setError,
|
setError,
|
||||||
setFocus,
|
setFocus,
|
||||||
|
setValue,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<DriverFormValues>({
|
} = useForm<DriverFormValues>({
|
||||||
// Cast the schema itself — the create/update schemas have structurally
|
// Cast both the schema argument and the resolver's result — same fix
|
||||||
// different required fields, so yup can't unify them into one type.
|
// already applied in TripFormModal: createDriverSchema/updateDriverSchema
|
||||||
resolver: yupResolver((isNew ? createDriverSchema : updateDriverSchema) as any),
|
// 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: {
|
defaultValues: {
|
||||||
name: editDriver?.name ?? "",
|
name: editDriver?.name ?? "",
|
||||||
phone: editDriver?.phone ?? "",
|
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("");
|
const [apiError, setApiError] = useState("");
|
||||||
|
|
||||||
// register("name") already attaches its own ref — focusing via setFocus
|
// register("name") already attaches its own ref — focusing via setFocus
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Spinner } from "../../UI";
|
import { Button, Modal, Spinner, Toast } from "../../UI";
|
||||||
import { PhotoCard } from "../DriverPhotos";
|
import { PhotoCard } from "../DriverPhotos";
|
||||||
import { getStoredToken } from "@/src/lib/auth";
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
import { archivedDriverService } from "@/src/services/archive/archivedDriver.service";
|
import { archivedDriverService } from "@/src/services/archive/archivedDriver.service";
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
NATIONAL_ID_TYPE_MAP,
|
NATIONAL_ID_TYPE_MAP,
|
||||||
} from "@/src/types/driver";
|
} from "@/src/types/driver";
|
||||||
import type { ArchivedDriver, DriverStatusHistoryEntry } from "@/src/types/driver";
|
import type { ArchivedDriver, DriverStatusHistoryEntry } from "@/src/types/driver";
|
||||||
|
import type { ToastNotification } from "@/src/Components/UI";
|
||||||
|
|
||||||
interface ArchivedDriverDetailModalProps {
|
interface ArchivedDriverDetailModalProps {
|
||||||
driverId: string;
|
driverId: string;
|
||||||
@@ -18,6 +19,7 @@ interface ArchivedDriverDetailModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── small helper components ───────────────────────────────────────────────────
|
// ── small helper components ───────────────────────────────────────────────────
|
||||||
|
// (No shared-UI equivalents — purpose-built layouts, not generic controls.)
|
||||||
|
|
||||||
/** Renders a single label/value row inside the detail body. */
|
/** Renders a single label/value row inside the detail body. */
|
||||||
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
function DetailRow({ label, value }: { label: string; value?: string | null }) {
|
||||||
@@ -55,38 +57,29 @@ function Avatar({ name }: { name: string }) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Modal showing the full profile of a single archived driver, including
|
* Modal showing the full profile of a single archived driver, including
|
||||||
* documents/photos and status history. Fetches `getById` and
|
* documents/photos and status history. Fetches `getById` on mount — the
|
||||||
* `getStatusHistory` in parallel on mount.
|
* archived detail response includes status history embedded.
|
||||||
*/
|
*/
|
||||||
export function ArchivedDriverDetailModal({ driverId, onClose }: ArchivedDriverDetailModalProps) {
|
export function ArchivedDriverDetailModal({ driverId, onClose }: ArchivedDriverDetailModalProps) {
|
||||||
const [driver, setDriver] = useState<ArchivedDriver | null>(null);
|
const [driver, setDriver] = useState<ArchivedDriver | null>(null);
|
||||||
const [history, setHistory] = useState<DriverStatusHistoryEntry[]>([]);
|
const [history, setHistory] = useState<DriverStatusHistoryEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [notification, setNotification] = useState<ToastNotification | null>(null);
|
||||||
|
|
||||||
// close on Escape
|
// fetch archived driver details on mount — status history comes embedded
|
||||||
useEffect(() => {
|
// in the same response, no separate call needed
|
||||||
const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
|
||||||
window.addEventListener("keydown", handler);
|
|
||||||
return () => window.removeEventListener("keydown", handler);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
// fetch archived driver details + status history on mount
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const [driverData, historyList] = await Promise.all([
|
const driverData = await archivedDriverService.getByIdUnwrapped(driverId, token);
|
||||||
archivedDriverService.getByIdUnwrapped(driverId, token),
|
|
||||||
archivedDriverService.getStatusHistoryUnwrapped(driverId, token),
|
|
||||||
]);
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setDriver(driverData);
|
setDriver(driverData);
|
||||||
setHistory(historyList ?? []);
|
setHistory(driverData.statusHistory ?? []);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setError("تعذّر تحميل بيانات السائق المؤرشف. يرجى المحاولة لاحقاً.");
|
if (!cancelled) setNotification({ type: "error", message: "تعذّر تحميل بيانات السائق المؤرشف. يرجى المحاولة لاحقاً." });
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -102,60 +95,19 @@ export function ArchivedDriverDetailModal({ driverId, onClose }: ArchivedDriverD
|
|||||||
|
|
||||||
// ── render ────────────────────────────────────────────────────────────────
|
// ── render ────────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="archived-driver-detail-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
title={driver?.name ?? "عرض السائق"}
|
||||||
style={{
|
subtitle="سائق مؤرشف"
|
||||||
position: "fixed", inset: 0, zIndex: 55,
|
onClose={onClose}
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
zIndex={60}
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
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 */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
<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 */}
|
{/* error */}
|
||||||
{!loading && error && (
|
{!loading && <Toast notification={notification} onDismiss={() => setNotification(null)} />}
|
||||||
<div style={{
|
|
||||||
padding: "1rem 1.25rem",
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
background: "#FEF2F2", border: "1px solid #FECACA",
|
|
||||||
fontSize: 13, color: "#991B1B", fontWeight: 500,
|
|
||||||
textAlign: "center",
|
|
||||||
}}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* content */}
|
{/* content */}
|
||||||
{!loading && driver && (
|
{!loading && driver && (
|
||||||
@@ -271,31 +213,6 @@ export function ArchivedDriverDetailModal({ driverId, onClose }: ArchivedDriverD
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Modal>
|
||||||
|
|
||||||
{/* ── 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Spinner } from "../../UI";
|
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||||
import { DRIVER_STATUS_MAP } from "@/src/types/driver";
|
import { DRIVER_STATUS_MAP } from "@/src/types/driver";
|
||||||
import type { ArchivedDriver } from "@/src/types/driver";
|
import type { ArchivedDriver } from "@/src/types/driver";
|
||||||
|
|
||||||
// ── status badge ─────────────────────────────────────────────────────────────
|
// ── 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"] }) {
|
function StatusBadge({ status }: { status: ArchivedDriver["status"] }) {
|
||||||
const cfg = DRIVER_STATUS_MAP[status] ?? DRIVER_STATUS_MAP.Inactive;
|
const cfg = DRIVER_STATUS_MAP[status] ?? DRIVER_STATUS_MAP.Inactive;
|
||||||
return (
|
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 ─────────────────────────────────────────────────────
|
// ── card / header styles ─────────────────────────────────────────────────────
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
||||||
@@ -75,11 +68,10 @@ export function ArchivedDriverTable({ drivers, loading, search, page, pages, onV
|
|||||||
</div>
|
</div>
|
||||||
) : drivers.length === 0 ? (
|
) : drivers.length === 0 ? (
|
||||||
/* empty state */
|
/* empty state */
|
||||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
<EmptyState
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
icon="🗄️"
|
||||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد سائقون في الأرشيف."}
|
title={search ? `لا توجد نتائج لـ "${search}"` : "لا يوجد سائقون في الأرشيف."}
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
/* data rows */
|
/* data rows */
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
<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>
|
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
{[
|
<Button
|
||||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
type="button"
|
||||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
variant="secondary"
|
||||||
].map(btn => (
|
size="sm"
|
||||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||||
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)" }}>
|
disabled={page === 1}
|
||||||
{btn.label}
|
>
|
||||||
</button>
|
السابق
|
||||||
))}
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||||
|
disabled={page === pages}
|
||||||
|
>
|
||||||
|
التالي
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Input, Modal } from "../../UI";
|
import { Input, Modal, Toast } from "../../UI";
|
||||||
import { ArchivedDriverTable } from "./ArchivedDriverTable";
|
import { ArchivedDriverTable } from "./ArchivedDriverTable";
|
||||||
import { ArchivedDriverDetailModal } from "./ArchivedDriverDetailModal";
|
import { ArchivedDriverDetailModal } from "./ArchivedDriverDetailModal";
|
||||||
import { useArchivedDrivers } from "@/src/hooks/archive/useArchivedDrivers";
|
import { useArchivedDrivers } from "@/src/hooks/archive/useArchivedDrivers";
|
||||||
@@ -34,9 +34,9 @@ export function ArchivedDrivers({ onClose }: ArchivedDriversProps) {
|
|||||||
const [viewDriverId, setViewDriverId] = useState<string | null>(null);
|
const [viewDriverId, setViewDriverId] = useState<string | null>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
drivers, loading, total, pages, error,
|
drivers, loading, total, pages, notification,
|
||||||
page, search,
|
page, search,
|
||||||
setPage, handleSearch, clearError,
|
setPage, handleSearch, clearNotification,
|
||||||
} = useArchivedDrivers();
|
} = useArchivedDrivers();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -65,7 +65,7 @@ export function ArchivedDrivers({ onClose }: ArchivedDriversProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <Alert type="error" message={error} onClose={clearError} />}
|
<Toast notification={notification} onDismiss={clearNotification} />
|
||||||
|
|
||||||
<ArchivedDriverTable
|
<ArchivedDriverTable
|
||||||
drivers={drivers}
|
drivers={drivers}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react";
|
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 { orderService } from "@/src/services/order.service";
|
||||||
import type { Order, OrderStatus, UpdateOrderStatusPayload } from "@/src/types/order";
|
import type { Order, OrderStatus, UpdateOrderStatusPayload } from "@/src/types/order";
|
||||||
|
|
||||||
@@ -277,18 +277,7 @@ export function OrderDetailPanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && <Alert type="error" message={error} />}
|
||||||
<div style={{
|
|
||||||
borderRadius: "var(--radius-md)",
|
|
||||||
background: "#FEF2F2",
|
|
||||||
border: "1px solid #FECACA",
|
|
||||||
padding: "0.75rem 1rem",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "#DC2626",
|
|
||||||
}}>
|
|
||||||
⚠ {error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{order && !loading && (
|
{order && !loading && (
|
||||||
<>
|
<>
|
||||||
@@ -388,57 +377,31 @@ export function OrderDetailPanel({
|
|||||||
<Alert type="error" message={statusError} onClose={() => setStatusError(null)} />
|
<Alert type="error" message={statusError} onClose={() => setStatusError(null)} />
|
||||||
)}
|
)}
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
|
||||||
<select
|
<Select
|
||||||
value={statusDraft}
|
value={statusDraft}
|
||||||
onChange={(e) => setStatusDraft(e.target.value as OrderStatus)}
|
onChange={(e) => setStatusDraft(e.target.value as OrderStatus)}
|
||||||
dir="rtl"
|
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]) => (
|
{Object.entries(ORDER_STATUS_MAP).map(([k, v]) => (
|
||||||
<option key={k} value={k}>{v.label}</option>
|
<option key={k} value={k}>{v.label}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
<input
|
<Input
|
||||||
|
label="سبب التغيير"
|
||||||
type="text"
|
type="text"
|
||||||
value={statusReason}
|
value={statusReason}
|
||||||
onChange={(e) => setStatusReason(e.target.value)}
|
onChange={(e) => setStatusReason(e.target.value)}
|
||||||
placeholder="سبب التغيير (اختياري)"
|
placeholder="سبب التغيير (اختياري)"
|
||||||
dir="rtl"
|
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"
|
type="button"
|
||||||
onClick={handleStatusUpdate}
|
onClick={handleStatusUpdate}
|
||||||
disabled={updatingStatus || !statusDraft || statusDraft === order.currentStatus}
|
disabled={!statusDraft || statusDraft === order.currentStatus}
|
||||||
style={{
|
loading={updatingStatus}
|
||||||
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)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{updatingStatus && <Spinner size="sm" className="text-white" />}
|
|
||||||
{updatingStatus ? "جارٍ التحديث…" : "تحديث الحالة"}
|
{updatingStatus ? "جارٍ التحديث…" : "تحديث الحالة"}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{order.statusHistory && order.statusHistory.length > 0 && (
|
{order.statusHistory && order.statusHistory.length > 0 && (
|
||||||
@@ -492,55 +455,21 @@ export function OrderDetailPanel({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<Button type="button" variant="danger" onClick={() => onDelete(order)}>
|
||||||
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>
|
</Button>
|
||||||
|
|
||||||
<button
|
<Button type="button" variant="secondary" onClick={() => onEdit(order)}>
|
||||||
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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<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="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" />
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
تعديل
|
تعديل
|
||||||
</button>
|
</Button>
|
||||||
|
|
||||||
<button
|
<Button type="button" variant="secondary" fullWidth onClick={onClose}>
|
||||||
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</aside>
|
</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 = (
|
const numberField = (
|
||||||
field:
|
field:
|
||||||
| "quantity"
|
| "quantity"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Button, Modal } from "../../UI";
|
||||||
import { ORDER_STATUS_MAP } from "../OrderDetailBanel";
|
import { ORDER_STATUS_MAP } from "../OrderDetailBanel";
|
||||||
import type { ArchivedOrder } from "@/src/types/order";
|
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;
|
const s = ORDER_STATUS_MAP[order.currentStatus] ?? ORDER_STATUS_MAP.Created;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div role="dialog" aria-modal="true" aria-labelledby="archived-order-title"
|
<Modal
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
open
|
||||||
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" }}>
|
title={order.shipmentNumber}
|
||||||
<div onClick={e => e.stopPropagation()}
|
subtitle="طلب مؤرشف"
|
||||||
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" }}>
|
onClose={onClose}
|
||||||
<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)" }}>
|
size="md"
|
||||||
<div>
|
footer={
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#EA580C", fontWeight: 600, margin: 0 }}>طلب مؤرشف</p>
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
<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}
|
</Button>
|
||||||
</h2>
|
}
|
||||||
</div>
|
>
|
||||||
<button type="button" onClick={onClose} aria-label="إغلاق"
|
<div dir="rtl">
|
||||||
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }} dir="rtl">
|
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: s.color, marginBottom: 12 }}>
|
<span style={{ 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 }} />
|
<span style={{ width: 7, height: 7, borderRadius: "50%", background: s.dot }} />
|
||||||
{s.label}
|
{s.label}
|
||||||
@@ -80,14 +75,6 @@ export function ArchivedOrderDetailModal({ order, onClose }: ArchivedOrderDetail
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Spinner } from "../../UI";
|
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||||
import { ORDER_STATUS_MAP } from "../OrderDetailBanel";
|
import { ORDER_STATUS_MAP } from "../OrderDetailBanel";
|
||||||
import type { ArchivedOrder } from "@/src/types/order";
|
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>
|
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
||||||
</div>
|
</div>
|
||||||
) : orders.length === 0 ? (
|
) : orders.length === 0 ? (
|
||||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
<EmptyState
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
icon="🗄️"
|
||||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد طلبات في الأرشيف."}
|
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد طلبات في الأرشيف."}
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||||
{orders.map((o, i) => {
|
{orders.map((o, i) => {
|
||||||
@@ -88,14 +87,12 @@ export function ArchivedOrderTable({ orders, loading, search, page, pages, onVie
|
|||||||
{s.label}
|
{s.label}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: "flex", justifyContent: "center" }}>
|
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||||
<button type="button" title={`عرض ${o.shipmentNumber}`} aria-label={`عرض ${o.shipmentNumber}`}
|
<IconBtn title={`عرض ${o.shipmentNumber}`} color="#059669" bg="#ECFDF5" borderColor="#A7F3D0" onClick={() => onView(o)}>
|
||||||
onClick={e => { e.stopPropagation(); onView(o); }}
|
|
||||||
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: "1px solid #A7F3D0", background: "#ECFDF5", color: "#059669", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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" />
|
<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" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</IconBtn>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</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>
|
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
||||||
</span>
|
</span>
|
||||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
{[
|
<Button
|
||||||
{ label: "السابق", action: () => onPageChange(Math.max(1, page - 1)), disabled: page === 1 },
|
type="button"
|
||||||
{ label: "التالي", action: () => onPageChange(Math.min(pages, page + 1)), disabled: page === pages },
|
variant="secondary"
|
||||||
].map(btn => (
|
size="sm"
|
||||||
<button key={btn.label} type="button" onClick={btn.action} disabled={btn.disabled}
|
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||||
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)" }}>
|
disabled={page === 1}
|
||||||
{btn.label}
|
>
|
||||||
</button>
|
السابق
|
||||||
))}
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(Math.min(pages, page + 1))}
|
||||||
|
disabled={page === pages}
|
||||||
|
>
|
||||||
|
التالي
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</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";
|
"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 { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
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 { getStoredToken } from "@/src/lib/auth";
|
||||||
import {
|
import {
|
||||||
createTripSchema,
|
createTripSchema,
|
||||||
@@ -21,6 +33,8 @@ import type { DriverOption } from "@/src/types/driver";
|
|||||||
import type { CarOption } from "@/src/types/car";
|
import type { CarOption } from "@/src/types/car";
|
||||||
import type { BranchOption } from "@/src/types/branch";
|
import type { BranchOption } from "@/src/types/branch";
|
||||||
|
|
||||||
|
const FORM_ID = "trip-form";
|
||||||
|
|
||||||
// ── Shared styles ────────────────────────────────────────────────────────────
|
// ── Shared styles ────────────────────────────────────────────────────────────
|
||||||
// Only for section headings — everything field-level now comes from the
|
// Only for section headings — everything field-level now comes from the
|
||||||
// Input / Select / Textarea components themselves.
|
// Input / Select / Textarea components themselves.
|
||||||
@@ -82,29 +96,34 @@ export function TripFormModal({
|
|||||||
const [cars, setCars] = useState<CarOption[]>([]);
|
const [cars, setCars] = useState<CarOption[]>([]);
|
||||||
const [branches, setBranches] = useState<BranchOption[]>([]);
|
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 ────────────────────────────────────────────────────────
|
// ── 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 {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setError,
|
setError,
|
||||||
setFocus,
|
setFocus,
|
||||||
|
setValue,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<TripFormValues>({
|
} = useForm<TripFormValues>({
|
||||||
// Cast the schema itself — create/update schemas differ structurally in
|
// Cast both the schema argument and the resolver's result:
|
||||||
// which fields are required, so yup can't unify them into one type.
|
// - argument: createTripSchema/updateTripSchema have structurally
|
||||||
resolver: yupResolver<TripFormValues>((isNew ? createTripSchema : updateTripSchema) as any),
|
// 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: {
|
defaultValues: {
|
||||||
title: editTrip?.title ?? "",
|
title: editTrip?.title ?? "",
|
||||||
driverId: editTrip?.driverId ?? "",
|
driverId: editTrip?.driverId ?? editTrip?.driver?.id ?? "",
|
||||||
carId: editTrip?.carId ?? "",
|
carId: editTrip?.carId ?? editTrip?.car?.id ?? "",
|
||||||
branchId: editTrip?.branchId ?? "",
|
branchId: editTrip?.branchId ?? editTrip?.branch?.id ?? "",
|
||||||
status: editTrip?.status ?? "Scheduled",
|
status: editTrip?.status ?? "Scheduled",
|
||||||
startTime: editTrip?.startTime?.slice(0, 16) ?? "",
|
startTime: editTrip?.startTime?.slice(0, 16) ?? "",
|
||||||
endTime: editTrip?.endTime?.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("");
|
const [apiError, setApiError] = useState("");
|
||||||
|
|
||||||
// register("title") already attaches its own ref — Input forwards it
|
// register("title") already attaches its own ref — Input forwards it
|
||||||
@@ -127,13 +171,6 @@ export function TripFormModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFocus("title");
|
setFocus("title");
|
||||||
}, [setFocus]);
|
}, [setFocus]);
|
||||||
useEffect(() => {
|
|
||||||
const h = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") onClose();
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", h);
|
|
||||||
return () => window.removeEventListener("keydown", h);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const numberField = (
|
const numberField = (
|
||||||
field: "collectedCount" | "deliveredCount" | "returnedCount" | "totalCashCollected",
|
field: "collectedCount" | "deliveredCount" | "returnedCount" | "totalCashCollected",
|
||||||
@@ -189,85 +226,28 @@ export function TripFormModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog"
|
open
|
||||||
aria-modal="true"
|
onClose={onClose}
|
||||||
aria-labelledby="trip-form-title"
|
size="lg"
|
||||||
onClick={(e) => {
|
title={isNew ? "إضافة رحلة جديدة" : "تعديل بيانات الرحلة"}
|
||||||
if (e.target === e.currentTarget) onClose();
|
footer={
|
||||||
}}
|
<>
|
||||||
style={{
|
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||||
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>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
<Button type="submit" form={FORM_ID} variant="primary" loading={isSubmitting}>
|
||||||
|
{isNew ? "إضافة الرحلة" : "حفظ التغييرات"}
|
||||||
{/* Form */}
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
<form
|
<form
|
||||||
|
id={FORM_ID}
|
||||||
onSubmit={handleSubmit(submitHandler)}
|
onSubmit={handleSubmit(submitHandler)}
|
||||||
noValidate
|
noValidate
|
||||||
style={{
|
dir="rtl"
|
||||||
padding: "1.5rem",
|
className="flex flex-col gap-4"
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "1rem",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{errors.title?.type === "manual" && (
|
{errors.title?.type === "manual" && (
|
||||||
<Alert type="error" message={errors.title.message ?? ""} onClose={() => setApiError("")} />
|
<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>
|
</form>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
|
// src/Components/Trip/index.ts
|
||||||
// CHANGE: removed TripDeleteModal export (Issue 1).
|
// 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;
|
subtitle?: string;
|
||||||
/** ARIA role for the dialog panel — "alertdialog" for destructive/confirm flows */
|
/** ARIA role for the dialog panel — "alertdialog" for destructive/confirm flows */
|
||||||
role?: "dialog" | "alertdialog";
|
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> = {
|
const MODAL_SIZES: Record<NonNullable<ModalProps["size"]>, string> = {
|
||||||
@@ -33,6 +35,7 @@ export function Modal({
|
|||||||
size = "md",
|
size = "md",
|
||||||
subtitle,
|
subtitle,
|
||||||
role = "dialog",
|
role = "dialog",
|
||||||
|
zIndex = 50,
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
// Escape key to close
|
// Escape key to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,7 +50,7 @@ export function Modal({
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Backdrop */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-[var(--color-slate-900)]/50 backdrop-blur-sm"
|
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 { ArchiveButton } from "./ArchiveButton"
|
||||||
export { FileInput } from "./FailInput"
|
export { FileInput } from "./FailInput"
|
||||||
|
export {IconBtn} from "./IconBtn"
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import type { Resolver } from "react-hook-form";
|
import type { Resolver } from "react-hook-form";
|
||||||
import { yupResolver } from "@hookform/resolvers/yup";
|
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 resolver: Resolver<UserFormData> = async (values, context, options) => {
|
||||||
const schema = isNew ? createUserSchema : updateUserSchema;
|
const schema = isNew ? createUserSchema : updateUserSchema;
|
||||||
const data = !isNew && !values.password ? { ...values, password: undefined } : values;
|
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 {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setError,
|
setError,
|
||||||
|
setValue,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<UserFormData>({
|
} = useForm<UserFormData>({
|
||||||
resolver,
|
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 submitHandler = async (data: UserFormData) => {
|
||||||
const payload: Partial<UserFormData> = { ...data };
|
const payload: Partial<UserFormData> = { ...data };
|
||||||
if (!isNew && !payload.password) delete payload.password;
|
if (!isNew && !payload.password) delete payload.password;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Badge, Button, EmptyState, Spinner } from "../UI";
|
import { Badge, Button, EmptyState, IconBtn, Spinner } from "../UI";
|
||||||
import type { User } from "@/src/types/user";
|
import type { User } from "@/src/types/user";
|
||||||
|
|
||||||
// ── status badge ─────────────────────────────────────────────────────────────
|
// ── 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 ─────────────────────────────────────────────────────
|
// ── card / header styles ─────────────────────────────────────────────────────
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export function ArchivedUserDetailModal({ userId, onClose }: ArchivedUserDetailM
|
|||||||
subtitle="مستخدم مؤرشف"
|
subtitle="مستخدم مؤرشف"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size="md"
|
size="md"
|
||||||
|
zIndex={60}
|
||||||
footer={
|
footer={
|
||||||
<Button type="button" variant="secondary" onClick={onClose}>
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
إغلاق
|
إغلاق
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, EmptyState, Spinner } from "../../UI";
|
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||||
import type { ArchivedUser } from "@/src/types/user";
|
import type { ArchivedUser } from "@/src/types/user";
|
||||||
|
|
||||||
// ── status badge ─────────────────────────────────────────────────────────────
|
// ── 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 ─────────────────────────────────────────────────────
|
// ── card / header styles ─────────────────────────────────────────────────────
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Spinner } from "../UI";
|
import { Alert, Badge, Button, IconBtn, Spinner } from "../UI";
|
||||||
import { CarImageGallery } from "./CarImageGallery";
|
import { CarImageGallery } from "./CarImageGallery";
|
||||||
import { useCarDetail } from "@/src/hooks/useCars";
|
import { useCarDetail } from "@/src/hooks/useCars";
|
||||||
import { STATUS_MAP, INS_MAP, fmtDate, isExpiringSoon } from "@/src/types/car";
|
import { STATUS_MAP, INS_MAP, fmtDate, isExpiringSoon } from "@/src/types/car";
|
||||||
@@ -17,7 +17,7 @@ interface CarDetailPanelProps {
|
|||||||
onDelete: (car: Car) => void;
|
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 }: {
|
function DetailRow({ label, value, mono = false, warn = false }: {
|
||||||
label: string; value: string; mono?: boolean; warn?: boolean;
|
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
|
<aside
|
||||||
aria-label="تفاصيل المركبة"
|
aria-label="تفاصيل المركبة"
|
||||||
style={{
|
style={{
|
||||||
@@ -96,10 +98,15 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
|
|||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={onClose} aria-label="إغلاق"
|
<IconBtn
|
||||||
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 }}>
|
title="إغلاق"
|
||||||
×
|
color="var(--color-text-muted)"
|
||||||
</button>
|
bg="var(--color-surface)"
|
||||||
|
borderColor="var(--color-border)"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 18, lineHeight: 1 }}>×</span>
|
||||||
|
</IconBtn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,16 +119,14 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && <Alert type="error" message={error} />}
|
||||||
<div style={{ borderRadius: "var(--radius-md)", background: "#FEF2F2", border: "1px solid #FECACA", padding: "0.75rem 1rem", fontSize: 13, color: "#DC2626" }}>
|
|
||||||
⚠ {error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{car && !loading && (
|
{car && !loading && (
|
||||||
<>
|
<>
|
||||||
{/* Status badges */}
|
{/* Status badges */}
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1.25rem" }}>
|
<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];
|
const s = STATUS_MAP[car.currentStatus];
|
||||||
return (
|
return (
|
||||||
@@ -138,11 +143,7 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{!car.isActive && (
|
{!car.isActive && <Badge label="محذوف" color="red" />}
|
||||||
<span style={{ borderRadius: "var(--radius-full)", border: "1px solid #FECACA", background: "#FEF2F2", padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: "#DC2626" }}>
|
|
||||||
محذوف
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section: Basic Info */}
|
{/* Section: Basic Info */}
|
||||||
@@ -227,38 +228,39 @@ export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPa
|
|||||||
}}>
|
}}>
|
||||||
{/* Row 1: secondary views */}
|
{/* Row 1: secondary views */}
|
||||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||||
<button type="button" onClick={() => setGallery(true)}
|
<Button type="button" variant="secondary" style={{ flex: 1 }} 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)" }}>
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<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" />
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
<circle cx="8.5" cy="8.5" r="1.5" />
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
<polyline points="21 15 16 10 5 21" />
|
<polyline points="21 15 16 10 5 21" />
|
||||||
</svg>
|
</svg>
|
||||||
الصور
|
الصور
|
||||||
</button>
|
</Button>
|
||||||
<button type="button" onClick={() => router.push(`/dashboard/cars/${car.id}/maintenance`)}
|
<Button
|
||||||
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)" }}>
|
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">
|
<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" />
|
<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>
|
</svg>
|
||||||
عرض تاريخ الصيانة
|
عرض تاريخ الصيانة
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2: primary edit / delete */}
|
{/* Row 2: primary edit / delete */}
|
||||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||||
<button type="button" onClick={() => onEdit(car)}
|
<Button type="button" variant="primary" style={{ flex: 2 }} 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)" }}>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<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="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" />
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
تعديل
|
تعديل
|
||||||
</button>
|
</Button>
|
||||||
<button type="button" onClick={() => onDelete(car)}
|
<Button type="button" variant="danger" 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -89,40 +89,26 @@ export function CarFormModal({
|
|||||||
}: CarFormModalProps) {
|
}: CarFormModalProps) {
|
||||||
const isNew = editCar === null;
|
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 ────────────────────────────────────────────────────────
|
// ── 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 {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setError,
|
setError,
|
||||||
setFocus,
|
setFocus,
|
||||||
|
setValue,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<CarFormValues>({
|
} = useForm<CarFormValues>({
|
||||||
// The create/update schemas have structurally different required fields,
|
// Cast both the schema argument and the resolver's result — same fix
|
||||||
// so yup's inferred types don't unify — cast the schema itself (not the
|
// already applied in TripFormModal/DriverFormModal: createCarSchema/
|
||||||
// yupResolver() call) to sidestep the mismatch.
|
// updateCarSchema have structurally different required fields (union
|
||||||
resolver: yupResolver<CarFormValues>((isNew ? createCarSchema : updateCarSchema) as any),
|
// 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: {
|
defaultValues: {
|
||||||
manufacturer: editCar?.manufacturer ?? "",
|
manufacturer: editCar?.manufacturer ?? "",
|
||||||
model: editCar?.model ?? "",
|
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("");
|
const [apiError, setApiError] = useState("");
|
||||||
|
|
||||||
// register("manufacturer") already provides a ref for this input, so we
|
// register("manufacturer") already provides a ref for this input, so we
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
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 { useCarImages } from "@/src/hooks/useCars";
|
||||||
import { STAGE_MAP } from "@/src/types/car";
|
import { STAGE_MAP } from "@/src/types/car";
|
||||||
import type { Car, CarImage, ImageStage } 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>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||||
{/* Sort */}
|
{/* Sort */}
|
||||||
<select value={sortBy} onChange={e => setSortBy(e.target.value as "asc" | "desc")}
|
<Select
|
||||||
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)" }}>
|
value={sortBy}
|
||||||
|
onChange={e => setSortBy(e.target.value as "asc" | "desc")}
|
||||||
|
wrapperClassName="w-auto"
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
<option value="desc">الأحدث أولاً</option>
|
<option value="desc">الأحدث أولاً</option>
|
||||||
<option value="asc">الأقدم أولاً</option>
|
<option value="asc">الأقدم أولاً</option>
|
||||||
</select>
|
</Select>
|
||||||
|
|
||||||
{/* Stage selector for upload */}
|
{/* Stage selector for upload */}
|
||||||
<select value={stage} onChange={e => setStage(e.target.value as ImageStage)}
|
<Select
|
||||||
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)" }}>
|
value={stage}
|
||||||
|
onChange={e => setStage(e.target.value as ImageStage)}
|
||||||
|
wrapperClassName="w-auto"
|
||||||
|
className="h-9"
|
||||||
|
>
|
||||||
<option value="GENERAL">عام</option>
|
<option value="GENERAL">عام</option>
|
||||||
<option value="BEFORE">قبل</option>
|
<option value="BEFORE">قبل</option>
|
||||||
<option value="AFTER">بعد</option>
|
<option value="AFTER">بعد</option>
|
||||||
</select>
|
</Select>
|
||||||
|
|
||||||
{/* Upload button */}
|
{/* Upload button */}
|
||||||
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploading}
|
<Button
|
||||||
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)" }}>
|
type="button"
|
||||||
{uploading
|
size="sm"
|
||||||
? <><Spinner size="sm" className="text-white" /> جارٍ الرفع…</>
|
onClick={() => fileRef.current?.click()}
|
||||||
: <>
|
loading={uploading}
|
||||||
|
>
|
||||||
|
{!uploading && (
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
<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" />
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
</svg>
|
</svg>
|
||||||
رفع صور
|
)}
|
||||||
</>
|
{uploading ? "جارٍ الرفع…" : "رفع صور"}
|
||||||
}
|
</Button>
|
||||||
</button>
|
|
||||||
<input ref={fileRef} type="file" accept="image/*" multiple style={{ display: "none" }} onChange={handleUpload} />
|
<input ref={fileRef} type="file" accept="image/*" multiple style={{ display: "none" }} onChange={handleUpload} />
|
||||||
|
|
||||||
{/* Close */}
|
{/* Close */}
|
||||||
<button type="button" onClick={onClose} aria-label="إغلاق"
|
<IconBtn
|
||||||
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" }}>
|
title="إغلاق"
|
||||||
×
|
color="var(--color-text-muted)"
|
||||||
</button>
|
bg="var(--color-surface)"
|
||||||
|
borderColor="var(--color-border)"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 20, lineHeight: 1 }}>×</span>
|
||||||
|
</IconBtn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Error ── */}
|
{/* ── Error ── */}
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ padding: "0.75rem 1.5rem", background: "#FEF2F2", borderBottom: "1px solid #FECACA", fontSize: 13, color: "#DC2626", fontWeight: 500 }}>
|
<div style={{ padding: "0.75rem 1.5rem" }}>
|
||||||
⚠ {error}
|
<Alert type="error" message={error} onClose={() => setError(null)} />
|
||||||
<button onClick={() => setError(null)} style={{ marginRight: 8, fontSize: 14, background: "none", border: "none", color: "#DC2626", cursor: "pointer" }}>×</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -143,13 +156,11 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
|||||||
<span style={{ fontSize: 14, color: "var(--color-text-muted)" }}>جارٍ تحميل الصور…</span>
|
<span style={{ fontSize: 14, color: "var(--color-text-muted)" }}>جارٍ تحميل الصور…</span>
|
||||||
</div>
|
</div>
|
||||||
) : images.length === 0 ? (
|
) : images.length === 0 ? (
|
||||||
<div style={{ textAlign: "center", padding: "5rem 0" }}>
|
<EmptyState
|
||||||
<div style={{ fontSize: 48, marginBottom: 12 }}>📸</div>
|
icon="📸"
|
||||||
<p style={{ fontSize: 15, color: "var(--color-text-muted)", fontWeight: 600 }}>لا توجد صور بعد</p>
|
title="لا توجد صور بعد"
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-hint)", marginTop: 4 }}>
|
description='اضغط على "رفع صور" لإضافة أولى الصور لهذه المركبة.'
|
||||||
اضغط على "رفع صور" لإضافة أولى الصور لهذه المركبة.
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: "1rem" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: "1rem" }}>
|
||||||
{images.map(img => {
|
{images.map(img => {
|
||||||
@@ -204,8 +215,13 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
|||||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
{new Date(img.createdAt).toLocaleDateString("ar-SA", { month: "short", day: "numeric" })}
|
{new Date(img.createdAt).toLocaleDateString("ar-SA", { month: "short", day: "numeric" })}
|
||||||
</span>
|
</span>
|
||||||
<button type="button" onClick={() => handleDelete(img.id)} disabled={isDeleting} aria-label="حذف الصورة"
|
<IconBtn
|
||||||
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" }}>
|
title="حذف الصورة"
|
||||||
|
color="#DC2626"
|
||||||
|
bg="#FEF2F2"
|
||||||
|
borderColor="#FECACA"
|
||||||
|
onClick={() => handleDelete(img.id)}
|
||||||
|
>
|
||||||
{isDeleting
|
{isDeleting
|
||||||
? <Spinner size="sm" className="text-red-600" />
|
? <Spinner size="sm" className="text-red-600" />
|
||||||
: <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
: <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" />
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
|
||||||
</svg>
|
</svg>
|
||||||
}
|
}
|
||||||
</button>
|
</IconBtn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -243,6 +259,8 @@ export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
|||||||
onLoad={() => setLightboxLoaded(true)}
|
onLoad={() => setLightboxLoaded(true)}
|
||||||
onError={e => { (e.target as HTMLImageElement).src = "/file.svg"; 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 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>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { Alert, Badge, Button, Modal, Spinner } from "../../UI";
|
||||||
import { Spinner, Button } from "../../UI";
|
|
||||||
import { STATUS_MAP, INS_MAP, fmtDate, isExpiringSoon } from "@/src/types/car";
|
import { STATUS_MAP, INS_MAP, fmtDate, isExpiringSoon } from "@/src/types/car";
|
||||||
import type { Car, InsuranceStatus } 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) {
|
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 (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="archived-car-detail-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
title={car ? `${car.manufacturer} ${car.model}` : "عرض المركبة"}
|
||||||
style={{
|
subtitle="مركبة مؤرشفة"
|
||||||
position: "fixed", inset: 0, zIndex: 80,
|
onClose={onClose}
|
||||||
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
zIndex={60}
|
||||||
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
size="md"
|
||||||
}}
|
footer={
|
||||||
>
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
<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 }}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
}
|
||||||
|
>
|
||||||
{/* body */}
|
<div dir="rtl">
|
||||||
<div style={{ padding: "1.5rem", overflowY: "auto", maxHeight: "70vh" }} dir="rtl">
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 10, padding: "3rem 0", color: "var(--color-text-muted)" }}>
|
<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" />
|
<Spinner size="sm" className="text-blue-600" />
|
||||||
@@ -98,16 +56,14 @@ export function ArchivedCarDetailPanel({ car, loading, error, onClose }: Archive
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && error && (
|
{!loading && error && <Alert type="error" message={error} />}
|
||||||
<div style={{ padding: "1rem 1.25rem", borderRadius: "var(--radius-lg)", background: "#FEF2F2", border: "1px solid #FECACA", fontSize: 13, color: "#991B1B", fontWeight: 500, textAlign: "center" }}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && car && (
|
{!loading && car && (
|
||||||
<>
|
<>
|
||||||
{/* status badges */}
|
{/* status badges */}
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1.25rem" }}>
|
<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];
|
const s = STATUS_MAP[car.currentStatus];
|
||||||
return (
|
return (
|
||||||
@@ -124,9 +80,7 @@ export function ArchivedCarDetailPanel({ car, loading, error, onClose }: Archive
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
<span style={{ borderRadius: "var(--radius-full)", border: "1px solid #FECACA", background: "#FEF2F2", padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: "#DC2626" }}>
|
<Badge label="مؤرشفة" color="red" />
|
||||||
مؤرشفة
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DetailRow label="رقم اللوحة" value={`${car.plateLetters} ${car.plateNumber}`} mono />
|
<DetailRow label="رقم اللوحة" value={`${car.plateLetters} ${car.plateNumber}`} mono />
|
||||||
@@ -140,14 +94,6 @@ export function ArchivedCarDetailPanel({ car, loading, error, onClose }: Archive
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Modal>
|
||||||
{/* 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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Input, Button } from "../../UI";
|
import { Alert, Input, Modal } from "../../UI";
|
||||||
import { ArchivedCarTable } from "./Archivedcartable";
|
import { ArchivedCarTable } from "./Archivedcartable";
|
||||||
import { ArchivedCarDetailPanel } from "./Archivedcardetailpanel";
|
import { ArchivedCarDetailPanel } from "./Archivedcardetailpanel";
|
||||||
import { useArchivedCars } from "@/src/hooks/archive/Usearchivedcars";
|
import { useArchivedCars } from "@/src/hooks/archive/Usearchivedcars";
|
||||||
@@ -21,15 +21,12 @@ export function ArchivedCarsModal({ onClose }: ArchivedCarsModalProps) {
|
|||||||
} = useArchivedCars();
|
} = useArchivedCars();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Modal
|
||||||
role="dialog" aria-modal="true" aria-labelledby="archive-cars-modal-title"
|
open
|
||||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
title={`المركبات المؤرشفة (${total})`}
|
||||||
style={{
|
subtitle="الأرشيف"
|
||||||
position: "fixed", inset: 0, zIndex: 70,
|
onClose={onClose}
|
||||||
background: "rgba(15,23,42,0.6)", backdropFilter: "blur(4px)",
|
size="lg"
|
||||||
display: "flex", alignItems: "flex-start", justifyContent: "center",
|
|
||||||
padding: "2rem 1rem", overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{viewCar && (
|
{viewCar && (
|
||||||
<ArchivedCarDetailPanel
|
<ArchivedCarDetailPanel
|
||||||
@@ -40,48 +37,7 @@ export function ArchivedCarsModal({ onClose }: ArchivedCarsModalProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
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" }}>
|
|
||||||
{/* search */}
|
{/* search */}
|
||||||
<div style={{ maxWidth: 320 }}>
|
<div style={{ maxWidth: 320 }}>
|
||||||
<Input
|
<Input
|
||||||
@@ -111,7 +67,6 @@ export function ArchivedCarsModal({ onClose }: ArchivedCarsModalProps) {
|
|||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Spinner, Button } from "../../UI";
|
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||||
import { STATUS_MAP, fmtDateShort } from "@/src/types/car";
|
import { STATUS_MAP, fmtDateShort } from "@/src/types/car";
|
||||||
import type { Car } 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)",
|
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 ────────────────────────────────────────────────────────────────────
|
// ── Props ────────────────────────────────────────────────────────────────────
|
||||||
interface ArchivedCarTableProps {
|
interface ArchivedCarTableProps {
|
||||||
cars: Car[];
|
cars: Car[];
|
||||||
@@ -71,11 +49,10 @@ export function ArchivedCarTable({ cars, loading, search, page, pages, onView, o
|
|||||||
</div>
|
</div>
|
||||||
) : cars.length === 0 ? (
|
) : cars.length === 0 ? (
|
||||||
/* empty state */
|
/* empty state */
|
||||||
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
|
<EmptyState
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
|
icon="🚗"
|
||||||
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد مركبات في الأرشيف."}
|
title={search ? `لا توجد نتائج لـ "${search}"` : "لا توجد مركبات في الأرشيف."}
|
||||||
</p>
|
/>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
/* data rows */
|
/* data rows */
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Role } from "@/src/types/role";
|
import { Role } from "@/src/types/role";
|
||||||
import { Button, EmptyState, Spinner } from "../UI";
|
import { Button, EmptyState, IconBtn, Spinner } from "../UI";
|
||||||
|
|
||||||
// ── Status badge ──────────────────────────────────────────────────────────────
|
// ── Status badge ──────────────────────────────────────────────────────────────
|
||||||
// Kept custom rather than swapped for <Badge/>: Badge only renders a label
|
// 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 ────────────────────────────────────────────────────────────────────
|
// ── Styles ────────────────────────────────────────────────────────────────────
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ export function ArchivedRoleDetailModal({ roleId, onClose }: ArchivedRoleDetailM
|
|||||||
title={role?.name ?? "عرض الدور"}
|
title={role?.name ?? "عرض الدور"}
|
||||||
subtitle="دور مؤرشف"
|
subtitle="دور مؤرشف"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
zIndex={60}
|
||||||
size="md"
|
size="md"
|
||||||
footer={
|
footer={
|
||||||
<Button type="button" variant="secondary" onClick={onClose}>
|
<Button type="button" variant="secondary" onClick={onClose}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button, EmptyState, Spinner } from "../../UI";
|
import { Button, EmptyState, IconBtn, Spinner } from "../../UI";
|
||||||
import type { ArchivedRole } from "@/src/types/role";
|
import type { ArchivedRole } from "@/src/types/role";
|
||||||
|
|
||||||
// ── status badge ─────────────────────────────────────────────────────────────
|
// ── 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 ─────────────────────────────────────────────────────
|
// ── card / header styles ─────────────────────────────────────────────────────
|
||||||
const cardStyle: React.CSSProperties = {
|
const cardStyle: React.CSSProperties = {
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
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 { getStoredToken } from "@/src/lib/auth";
|
||||||
import { archivedDriverService } from "@/src/services/archive/archivedDriver.service";
|
import { archivedDriverService } from "@/src/services/archive/archivedDriver.service";
|
||||||
import type { ArchivedDriver } from "@/src/types/driver";
|
import type { ArchivedDriver } from "@/src/types/driver";
|
||||||
|
import type { ToastNotification } from "@/src/Components/UI";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads and paginates the archived drivers list, mirroring useArchivedUsers.
|
* Loads and paginates the archived drivers list, mirroring useArchivedUsers.
|
||||||
@@ -14,7 +15,12 @@ export function useArchivedDrivers() {
|
|||||||
const [pages, setPages] = useState(1);
|
const [pages, setPages] = useState(1);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState("");
|
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. */
|
/** Fetches the current page/search slice of archived drivers from the API. */
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -25,13 +31,12 @@ export function useArchivedDrivers() {
|
|||||||
setDrivers(res.data.data);
|
setDrivers(res.data.data);
|
||||||
setTotal(res.data.meta.total);
|
setTotal(res.data.meta.total);
|
||||||
setPages(res.data.meta.totalPages);
|
setPages(res.data.meta.totalPages);
|
||||||
setError(null);
|
|
||||||
} catch {
|
} catch {
|
||||||
setError("تعذّر تحميل قائمة السائقين المؤرشفين. يرجى المحاولة لاحقاً.");
|
notify({ type: "error", message: "تعذّر تحميل قائمة السائقين المؤرشفين. يرجى المحاولة لاحقاً." });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, search]);
|
}, [page, search, notify]);
|
||||||
|
|
||||||
useEffect(() => { queueMicrotask(load); }, [load]);
|
useEffect(() => { queueMicrotask(load); }, [load]);
|
||||||
|
|
||||||
@@ -42,8 +47,9 @@ export function useArchivedDrivers() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
drivers, loading, total, pages, page, search, error,
|
drivers, loading, total, pages, page, search,
|
||||||
setPage, handleSearch, clearError: () => setError(null),
|
notification,
|
||||||
|
setPage, handleSearch, clearNotification: () => setNotification(null),
|
||||||
refresh: load,
|
refresh: load,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -179,6 +179,7 @@ export function useDrivers() {
|
|||||||
updateDriver,
|
updateDriver,
|
||||||
deleteDriver,
|
deleteDriver,
|
||||||
notification,
|
notification,
|
||||||
|
clearNotification: () => setNotification(null),
|
||||||
reload: () => loadDrivers(page, search),
|
reload: () => loadDrivers(page, search),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@ useEffect(() => {
|
|||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
const res = await userService.update(id, data, token);
|
const res = await userService.update(id, data, token);
|
||||||
dispatch({ type: "UPDATE", user: res?.data });
|
dispatch({ type: "UPDATE", user: res });
|
||||||
notify({ type: "success", message: "تم تحديث بيانات المستخدم بنجاح." });
|
notify({ type: "success", message: "تم تحديث بيانات المستخدم بنجاح." });
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { get } from "../api";
|
|||||||
import type {
|
import type {
|
||||||
ArchivedDriverListResponse,
|
ArchivedDriverListResponse,
|
||||||
ArchivedDriverResponse,
|
ArchivedDriverResponse,
|
||||||
ArchivedDriverStatusHistoryResponse,
|
|
||||||
} from "@/src/types/driver";
|
} from "@/src/types/driver";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,17 +35,9 @@ export const archivedDriverService = {
|
|||||||
getById: (id: string, token: string | null) =>
|
getById: (id: string, token: string | null) =>
|
||||||
get<ArchivedDriverResponse>(`driver/archived/${id}`, token),
|
get<ArchivedDriverResponse>(`driver/archived/${id}`, token),
|
||||||
|
|
||||||
/**
|
getAllUnwrapped: async (page: number, search: string, token: string | null) => {
|
||||||
* 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) => {
|
|
||||||
const res = await archivedDriverService.getAll(page, search, token);
|
const res = await archivedDriverService.getAll(page, search, token);
|
||||||
return { items: res.data.data, total: res.data.meta.total, pages: res.data.meta.totalPages };
|
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,
|
getByIdUnwrapped: async (id: string, token: string | null) => (await archivedDriverService.getById(id, token)).data,
|
||||||
getStatusHistoryUnwrapped: async (id, token) => (await archivedDriverService.getStatusHistory(id, token)).data ?? [],
|
|
||||||
};
|
};
|
||||||
@@ -161,6 +161,7 @@ export interface ArchivedDriver {
|
|||||||
driverType?: string | null;
|
driverType?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
statusHistory?: DriverStatusHistoryEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /driver/archived
|
// GET /driver/archived
|
||||||
|
|||||||
@@ -86,11 +86,13 @@ export const createTripSchema = yup.object({
|
|||||||
|
|
||||||
notes: yup
|
notes: yup
|
||||||
.string()
|
.string()
|
||||||
|
.transform((value) => (value === "" ? undefined : value))
|
||||||
.min(2, "الملاحظات يجب أن تكون حرفين على الأقل") // doc
|
.min(2, "الملاحظات يجب أن تكون حرفين على الأقل") // doc
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
endReason: yup
|
endReason: yup
|
||||||
.string()
|
.string()
|
||||||
|
.transform((value) => (value === "" ? undefined : value))
|
||||||
.min(2, "سبب الانتهاء يجب أن يكون حرفين على الأقل") // doc
|
.min(2, "سبب الانتهاء يجب أن يكون حرفين على الأقل") // doc
|
||||||
.optional(),
|
.optional(),
|
||||||
});
|
});
|
||||||
@@ -159,11 +161,13 @@ export const updateTripSchema = yup.object({
|
|||||||
|
|
||||||
notes: yup
|
notes: yup
|
||||||
.string()
|
.string()
|
||||||
|
.transform((value) => (value === "" ? undefined : value))
|
||||||
.min(2, "الملاحظات يجب أن تكون حرفين على الأقل")
|
.min(2, "الملاحظات يجب أن تكون حرفين على الأقل")
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
endReason: yup
|
endReason: yup
|
||||||
.string()
|
.string()
|
||||||
|
.transform((value) => (value === "" ? undefined : value))
|
||||||
.min(2, "سبب الانتهاء يجب أن يكون حرفين على الأقل")
|
.min(2, "سبب الانتهاء يجب أن يكون حرفين على الأقل")
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user