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:
m7amedez5511
2026-07-22 14:19:32 +03:00
parent f1a521dd5a
commit b87d81100f
59 changed files with 2261 additions and 3261 deletions

View File

@@ -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}

View File

@@ -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 ── */}

View File

@@ -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,11 +222,11 @@ 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);
} }
}, [tripId]); }, [tripId]);
useEffect(() => { useEffect(() => {
queueMicrotask(loadTrip); queueMicrotask(loadTrip);
}, [loadTrip]); }, [loadTrip]);
@@ -240,7 +249,7 @@ export default function TripDetailPage() {
} }
}, },
[trip, notify], [trip, notify],
); );
// ── Delete confirm ──────────────────────────────────────────────────────── // ── Delete confirm ────────────────────────────────────────────────────────
const handleConfirmDelete = useCallback(async () => { const handleConfirmDelete = useCallback(async () => {

View File

@@ -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>
);
} }

View File

@@ -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="الأرشيف" />

View File

@@ -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;
@@ -91,7 +68,7 @@ export function BranchDetailModal({ branchId, onClose }: BranchDetailModalProps)
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [branchId]); }, [branchId]);
// ── helpers ─────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────
const fmt = (iso?: string | null) => const fmt = (iso?: string | null) =>
@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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>
)} )}

View File

@@ -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)} />
)}
</>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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

View File

@@ -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,44 +57,35 @@ 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);
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [driverId]); }, [driverId]);
// ── helpers ─────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────
const fmt = (iso?: string | null) => const fmt = (iso?: string | null) =>
@@ -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>
); );
} }

View File

@@ -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>
)} )}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>
); );
} }

View File

@@ -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>
)} )}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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";

View 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>
);
}

View File

@@ -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"

View File

@@ -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"

View File

@@ -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;

View File

@@ -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)",

View File

@@ -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}>
إغلاق إغلاق

View File

@@ -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)",

View File

@@ -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>
)} )}

View File

@@ -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

View File

@@ -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='اضغط على "رفع صور" لإضافة أولى الصور لهذه المركبة.'
اضغط على &quot;رفع صور&quot; لإضافة أولى الصور لهذه المركبة. />
</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>

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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 }}>

View File

@@ -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 = {

View File

@@ -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}>

View File

@@ -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)",

View File

@@ -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,
}; };
} }

View File

@@ -179,6 +179,7 @@ export function useDrivers() {
updateDriver, updateDriver,
deleteDriver, deleteDriver,
notification, notification,
clearNotification: () => setNotification(null),
reload: () => loadDrivers(page, search), reload: () => loadDrivers(page, search),
}; };
} }

View File

@@ -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) {

View File

@@ -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 ?? [],
}; };

View File

@@ -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

View File

@@ -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(),