create car page and update user page
This commit is contained in:
87
Components/car/CarDeleteModal.tsx
Normal file
87
Components/car/CarDeleteModal.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
// Components/Car/CarDeleteModal.tsx
|
||||||
|
// Confirmation dialog before soft-deleting a car.
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
import type { Car } from "../../types/car";
|
||||||
|
|
||||||
|
interface CarDeleteModalProps {
|
||||||
|
car: Car;
|
||||||
|
deleting: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CarDeleteModal({ car, deleting, onCancel, onConfirm }: CarDeleteModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alertdialog" aria-modal="true" aria-labelledby="car-del-title"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 60,
|
||||||
|
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "100%", maxWidth: 420,
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
boxShadow: "0 20px 48px rgba(0,0,0,.18)",
|
||||||
|
padding: "2rem",
|
||||||
|
display: "flex", flexDirection: "column", gap: "1rem",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 id="car-del-title" style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
|
حذف المركبة
|
||||||
|
</h2>
|
||||||
|
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
|
||||||
|
هل أنت متأكد من حذف{" "}
|
||||||
|
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||||
|
{car.manufacturer} {car.model}
|
||||||
|
</strong>
|
||||||
|
{" "}(
|
||||||
|
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
|
||||||
|
{car.plateLetters} {car.plateNumber}
|
||||||
|
</span>
|
||||||
|
)؟ لا يمكن التراجع عن هذا الإجراء.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
<button type="button" onClick={onCancel} disabled={deleting}
|
||||||
|
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: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onConfirm} disabled={deleting}
|
||||||
|
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
|
||||||
|
{deleting && <Spinner size="sm" className="text-white" />}
|
||||||
|
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
302
Components/car/CarDetailPanel.tsx
Normal file
302
Components/car/CarDetailPanel.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
"use client";
|
||||||
|
// Components/Car/CarDetailPanel.tsx
|
||||||
|
// Slide-in panel showing full car details with action buttons.
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
import { CarImageGallery } from "./CarImageGallery";
|
||||||
|
import { carService } from "../../services/car.service";
|
||||||
|
import { getStoredToken } from "../../lib/auth";
|
||||||
|
import type { Car, CarStatus, InsuranceStatus } from "../../types/car";
|
||||||
|
|
||||||
|
// ── Status config ─────────────────────────────────────────────────────────────
|
||||||
|
const STATUS_MAP: Record<CarStatus, { label: string; color: string; bg: string; border: string }> = {
|
||||||
|
Active: { label: "نشط", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0" },
|
||||||
|
InMaintenance: { label: "صيانة", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A" },
|
||||||
|
InTrip: { label: "في رحلة", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE" },
|
||||||
|
Inactive: { label: "غير نشط", color: "#64748B", bg: "#F1F5F9", border: "#E2E8F0" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const INS_MAP: Record<InsuranceStatus, { label: string; color: string }> = {
|
||||||
|
Valid: { label: "سارٍ", color: "#16A34A" },
|
||||||
|
Expired: { label: "منتهي", color: "#DC2626" },
|
||||||
|
NotInsured: { label: "غير مؤمَّن", color: "#D97706" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helper: format date ───────────────────────────────────────────────────────
|
||||||
|
function fmtDate(iso?: string | null): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||||
|
year: "numeric", month: "long", day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the date is within 90 days in the future (or already past) */
|
||||||
|
function isExpiringSoon(iso?: string | null): boolean {
|
||||||
|
if (!iso) return false;
|
||||||
|
const diff = new Date(iso).getTime() - Date.now();
|
||||||
|
return diff <= 90 * 86_400_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
interface CarDetailPanelProps {
|
||||||
|
carId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onEdit: (car: Car) => void;
|
||||||
|
onDelete: (car: Car) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Row component ─────────────────────────────────────────────────────────────
|
||||||
|
function DetailRow({ label, value, mono = false, warn = false }: { label: string; value: string; mono?: boolean; warn?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: "flex", justifyContent: "space-between", alignItems: "baseline",
|
||||||
|
padding: "0.6rem 0",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>{label}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
|
||||||
|
color: warn ? "#D97706" : "var(--color-text-primary)",
|
||||||
|
fontWeight: warn ? 600 : 400,
|
||||||
|
}}>
|
||||||
|
{warn && value !== "—" ? "⚠ " : ""}{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPanelProps) {
|
||||||
|
const [car, setCar] = useState<Car | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [gallery, setGallery] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = getStoredToken();
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
carService
|
||||||
|
.getById(carId, token)
|
||||||
|
.then(res => setCar((res as unknown as { data: Car }).data))
|
||||||
|
.catch((err: Error) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [carId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => { if (e.key === "Escape" && !gallery) onClose(); };
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [gallery, onClose]);
|
||||||
|
|
||||||
|
// ── Render ───────────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
onClick={onClose}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 40,
|
||||||
|
background: "rgba(15,23,42,0.45)", backdropFilter: "blur(2px)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<aside
|
||||||
|
aria-label="تفاصيل المركبة"
|
||||||
|
style={{
|
||||||
|
position: "fixed", top: 0, left: 0, bottom: 0, zIndex: 50,
|
||||||
|
width: "min(480px, 100vw)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderRight: "1px solid var(--color-border)",
|
||||||
|
display: "flex", flexDirection: "column",
|
||||||
|
boxShadow: "8px 0 40px rgba(0,0,0,.18)",
|
||||||
|
overflowY: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{
|
||||||
|
padding: "1.25rem 1.5rem",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
|
||||||
|
تفاصيل المركبة
|
||||||
|
</p>
|
||||||
|
{car && (
|
||||||
|
<h2 style={{ fontSize: 18, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
|
||||||
|
{car.manufacturer} {car.model} {car.year}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={onClose} aria-label="إغلاق"
|
||||||
|
style={{ width: 34, height: 34, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "1.5rem" }}>
|
||||||
|
{loading && (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "2rem 0" }}>
|
||||||
|
<Spinner size="sm" className="text-blue-600" />
|
||||||
|
<span style={{ fontSize: 13, color: "var(--color-text-muted)" }}>جارٍ التحميل…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 && (
|
||||||
|
<>
|
||||||
|
{/* Status badges */}
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: "1.25rem" }}>
|
||||||
|
{(() => {
|
||||||
|
const s = STATUS_MAP[car.currentStatus];
|
||||||
|
return (
|
||||||
|
<span style={{ borderRadius: "var(--radius-full)", border: `1px solid ${s.border}`, background: s.bg, padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: s.color }}>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{car.insuranceStatus && (() => {
|
||||||
|
const ins = INS_MAP[car.insuranceStatus!];
|
||||||
|
return (
|
||||||
|
<span style={{ borderRadius: "var(--radius-full)", border: `1px solid ${ins.color}33`, background: `${ins.color}11`, padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: ins.color }}>
|
||||||
|
تأمين: {ins.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{!car.isActive && (
|
||||||
|
<span style={{ borderRadius: "var(--radius-full)", border: "1px solid #FECACA", background: "#FEF2F2", padding: "0.3rem 0.875rem", fontSize: 12, fontWeight: 700, color: "#DC2626" }}>
|
||||||
|
محذوف
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section: Basic Info */}
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, marginBottom: "0.5rem" }}>
|
||||||
|
بيانات أساسية
|
||||||
|
</p>
|
||||||
|
<DetailRow label="الشركة المصنعة" value={car.manufacturer} />
|
||||||
|
<DetailRow label="الموديل" value={car.model} />
|
||||||
|
<DetailRow label="سنة الصنع" value={String(car.year)} />
|
||||||
|
<DetailRow label="اللون" value={car.color ?? "—"} />
|
||||||
|
<DetailRow label="الفرع" value={car.branch?.name ?? "—"} />
|
||||||
|
|
||||||
|
{/* Section: Plate */}
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "1.25rem 0 0.5rem" }}>
|
||||||
|
بيانات اللوحة
|
||||||
|
</p>
|
||||||
|
<DetailRow label="رقم اللوحة" value={`${car.plateLetters} ${car.plateNumber}`} mono />
|
||||||
|
<DetailRow label="نوع اللوحة" value={car.plateType ?? "—"} />
|
||||||
|
<DetailRow label="رقم الاستمارة" value={car.registrationNumber ?? "—"} mono />
|
||||||
|
<DetailRow label="رقم الهيكل (VIN)" value={car.vinNumber ?? "—"} mono />
|
||||||
|
|
||||||
|
{/* Section: Regulatory */}
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "1.25rem 0 0.5rem" }}>
|
||||||
|
الترخيص والتأمين
|
||||||
|
</p>
|
||||||
|
<DetailRow label="انتهاء الاستمارة" value={fmtDate(car.registrationExpiryDate)} warn={isExpiringSoon(car.registrationExpiryDate)} />
|
||||||
|
<DetailRow label="انتهاء التأمين" value={fmtDate(car.insuranceExpiryDate)} warn={isExpiringSoon(car.insuranceExpiryDate)} />
|
||||||
|
<DetailRow label="انتهاء الفحص الدوري" value={fmtDate(car.inspectionExpiryDate)} warn={isExpiringSoon(car.inspectionExpiryDate)} />
|
||||||
|
<DetailRow label="رقم بطاقة التشغيل" value={car.operationCardNumber ?? "—"} mono />
|
||||||
|
<DetailRow label="انتهاء بطاقة التشغيل" value={fmtDate(car.operationCardExpiry)} />
|
||||||
|
|
||||||
|
{/* Section: Operational */}
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "1.25rem 0 0.5rem" }}>
|
||||||
|
بيانات تشغيلية
|
||||||
|
</p>
|
||||||
|
<DetailRow label="رقم GPS" value={car.gpsDeviceId ?? "—"} mono />
|
||||||
|
<DetailRow label="الطاقة الاستيعابية" value={car.capacity != null ? String(car.capacity) : "—"} />
|
||||||
|
<DetailRow label="الوزن (كجم)" value={car.weight != null ? String(car.weight) : "—"} />
|
||||||
|
<DetailRow label="حالة الحجز" value={car.currentImpoundStatus ?? "بدون"} />
|
||||||
|
<DetailRow label="تاريخ الإضافة" value={fmtDate(car.createdAt)} />
|
||||||
|
<DetailRow label="آخر تحديث" value={fmtDate(car.updatedAt)} />
|
||||||
|
|
||||||
|
{/* Status History */}
|
||||||
|
{car.statusHistory && car.statusHistory.length > 0 && (
|
||||||
|
<>
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: "1.25rem 0 0.75rem" }}>
|
||||||
|
سجل الحالات
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{car.statusHistory.slice(0, 6).map(h => {
|
||||||
|
const s = STATUS_MAP[h.carStatus] ?? STATUS_MAP.Active;
|
||||||
|
return (
|
||||||
|
<div key={h.id} style={{
|
||||||
|
display: "flex", justifyContent: "space-between", alignItems: "center",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: `1px solid ${s.border}`,
|
||||||
|
background: s.bg,
|
||||||
|
padding: "0.5rem 0.875rem",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: s.color }}>{s.label}</span>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
|
{fmtDate(h.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer actions */}
|
||||||
|
{car && (
|
||||||
|
<div style={{
|
||||||
|
padding: "1rem 1.5rem",
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
display: "flex", gap: "0.75rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{/* Gallery */}
|
||||||
|
<button type="button" onClick={() => setGallery(true)}
|
||||||
|
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||||
|
<circle cx="8.5" cy="8.5" r="1.5" />
|
||||||
|
<polyline points="21 15 16 10 5 21" />
|
||||||
|
</svg>
|
||||||
|
الصور
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Edit */}
|
||||||
|
<button type="button" onClick={() => onEdit(car)}
|
||||||
|
style={{ flex: 2, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<button type="button" onClick={() => onDelete(car)}
|
||||||
|
style={{ height: 40, padding: "0 1rem", borderRadius: "var(--radius-md)", border: "1px solid #FECACA", background: "#FEF2F2", fontSize: 13, fontWeight: 700, color: "#DC2626", cursor: "pointer", fontFamily: "var(--font-sans)" }}>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Image gallery overlay */}
|
||||||
|
{gallery && car && (
|
||||||
|
<CarImageGallery car={car} onClose={() => setGallery(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
761
Components/car/CarFormModal.tsx
Normal file
761
Components/car/CarFormModal.tsx
Normal file
@@ -0,0 +1,761 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Alert, Spinner } from "../UI";
|
||||||
|
import { get } from "../../services/api";
|
||||||
|
import { getStoredToken } from "../../lib/auth";
|
||||||
|
import type {
|
||||||
|
Car,
|
||||||
|
CarFormErrors,
|
||||||
|
CreateCarPayload,
|
||||||
|
UpdateCarPayload,
|
||||||
|
} from "../../types/car";
|
||||||
|
import type { Branch } from "../../types/branch";
|
||||||
|
|
||||||
|
// ── Shared input style ───────────────────────────────────────────────────────
|
||||||
|
const inputBase: React.CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
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)",
|
||||||
|
outline: "none",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelStyle: React.CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorTextStyle: React.CSSProperties = {
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-danger)",
|
||||||
|
fontWeight: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Validation ───────────────────────────────────────────────────────────────
|
||||||
|
function validate(form: Partial<CreateCarPayload>): CarFormErrors {
|
||||||
|
const e: CarFormErrors = {};
|
||||||
|
if (!form.manufacturer?.trim()) e.manufacturer = "الشركة المصنعة مطلوبة";
|
||||||
|
if (!form.model?.trim()) e.model = "الموديل مطلوب";
|
||||||
|
if (
|
||||||
|
!form.year ||
|
||||||
|
form.year < 1900 ||
|
||||||
|
form.year > new Date().getFullYear() + 1
|
||||||
|
)
|
||||||
|
e.year = "سنة غير صالحة";
|
||||||
|
if (!form.plateNumber?.trim()) e.plateNumber = "رقم اللوحة مطلوب";
|
||||||
|
if (!form.plateLetters?.trim()) e.plateLetters = "حروف اللوحة مطلوبة";
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Date helper ──────────────────────────────────────────────────────────────
|
||||||
|
// <input type="date"> gives "YYYY-MM-DD"; the backend z.string().datetime()
|
||||||
|
// requires a full ISO-8601 string ("YYYY-MM-DDTHH:mm:ss.sssZ").
|
||||||
|
// We keep state as the short form (so the input renders correctly) and convert
|
||||||
|
// only at submit time.
|
||||||
|
function toIsoDateTime(val: string): string {
|
||||||
|
if (!val) return val;
|
||||||
|
if (val.includes("T")) return val; // Already in ISO format, likely from an existing record — just return as-is.
|
||||||
|
return `${val}T00:00:00.000Z`; // transform "2026-12-31" → "2026-12-31T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Props ────────────────────────────────────────────────────────────────────
|
||||||
|
interface CarFormModalProps {
|
||||||
|
editCar: Car | null;
|
||||||
|
/** Pass pre-loaded branches or an empty array — the modal will auto-fetch if empty. */
|
||||||
|
branches: Branch[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (
|
||||||
|
payload: CreateCarPayload | UpdateCarPayload,
|
||||||
|
isNew: boolean,
|
||||||
|
) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ────────────────────────────────────────────────────────────────
|
||||||
|
export function CarFormModal({
|
||||||
|
editCar,
|
||||||
|
branches: branchesProp,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
}: CarFormModalProps) {
|
||||||
|
const isNew = editCar === null;
|
||||||
|
|
||||||
|
// ── Local branches (auto-fetched if prop is empty) ─────────────────────────
|
||||||
|
const [branches, setBranches] = useState<Branch[]>(branchesProp);
|
||||||
|
useEffect(() => {
|
||||||
|
if (branchesProp.length > 0) {
|
||||||
|
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 — user can still submit */
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Form state — each field correctly typed ────────────────────────────────
|
||||||
|
// String fields
|
||||||
|
const [manufacturer, setManufacturer] = useState(editCar?.manufacturer ?? "");
|
||||||
|
const [model, setModel] = useState(editCar?.model ?? "");
|
||||||
|
const [color, setColor] = useState(editCar?.color ?? "");
|
||||||
|
const [plateNumber, setPlateNumber] = useState(editCar?.plateNumber ?? "");
|
||||||
|
const [plateLetters, setPlateLetters] = useState(editCar?.plateLetters ?? "");
|
||||||
|
const [plateType, setPlateType] = useState(editCar?.plateType ?? "");
|
||||||
|
const [registrationNumber, setRegistrationNumber] = useState(
|
||||||
|
editCar?.registrationNumber ?? "",
|
||||||
|
);
|
||||||
|
const [vinNumber, setVinNumber] = useState(editCar?.vinNumber ?? "");
|
||||||
|
const [branchId, setBranchId] = useState(editCar?.branch?.id ?? "");
|
||||||
|
const [currentStatus, setCurrentStatus] = useState(
|
||||||
|
editCar?.currentStatus ?? "Active",
|
||||||
|
);
|
||||||
|
const [insuranceStatus, setInsuranceStatus] = useState(
|
||||||
|
editCar?.insuranceStatus ?? "Valid",
|
||||||
|
);
|
||||||
|
const [registrationExpiryDate, setRegistrationExpiryDate] = useState(
|
||||||
|
editCar?.registrationExpiryDate?.slice(0, 10) ?? "",
|
||||||
|
);
|
||||||
|
const [insuranceExpiryDate, setInsuranceExpiryDate] = useState(
|
||||||
|
editCar?.insuranceExpiryDate?.slice(0, 10) ?? "",
|
||||||
|
);
|
||||||
|
const [inspectionExpiryDate, setInspectionExpiryDate] = useState(
|
||||||
|
editCar?.inspectionExpiryDate?.slice(0, 10) ?? "",
|
||||||
|
);
|
||||||
|
const [gpsDeviceId, setGpsDeviceId] = useState(editCar?.gpsDeviceId ?? "");
|
||||||
|
// Numeric fields — undefined when blank
|
||||||
|
const [year, setYear] = useState<number | undefined>(
|
||||||
|
editCar?.year ?? new Date().getFullYear(),
|
||||||
|
);
|
||||||
|
const [capacity, setCapacity] = useState<number | undefined>(
|
||||||
|
editCar?.capacity ?? undefined,
|
||||||
|
);
|
||||||
|
const [weight, setWeight] = useState<number | undefined>(
|
||||||
|
editCar?.weight ?? undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<CarFormErrors>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [apiError, setApiError] = useState("");
|
||||||
|
const firstRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
firstRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// ── Number input helper ───────────────────────────────────────────────────
|
||||||
|
const parseNum = (v: string): number | undefined =>
|
||||||
|
v.trim() === "" ? undefined : Number(v);
|
||||||
|
|
||||||
|
// ── Input error style ─────────────────────────────────────────────────────
|
||||||
|
const inputStyle = (field: keyof CarFormErrors): React.CSSProperties => ({
|
||||||
|
...inputBase,
|
||||||
|
...(errors[field]
|
||||||
|
? { borderColor: "var(--color-danger)", background: "#FEF2F2" }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearFieldError = (field: keyof CarFormErrors) =>
|
||||||
|
setErrors((p) => ({ ...p, [field]: undefined }));
|
||||||
|
|
||||||
|
// ── Submit ────────────────────────────────────────────────────────────────
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Build a partial payload for validation (only required fields matter here)
|
||||||
|
const forValidation: Partial<CreateCarPayload> = {
|
||||||
|
manufacturer,
|
||||||
|
model,
|
||||||
|
year,
|
||||||
|
plateNumber,
|
||||||
|
plateLetters,
|
||||||
|
};
|
||||||
|
const errs = validate(forValidation);
|
||||||
|
if (Object.keys(errs).length) {
|
||||||
|
setErrors(errs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the full payload — strip empty strings to avoid schema rejection.
|
||||||
|
// We use `unknown` cast here to satisfy TypeScript while we construct the
|
||||||
|
// object dynamically; the structure still fully matches CreateCarPayload.
|
||||||
|
const raw: Record<string, unknown> = {
|
||||||
|
manufacturer,
|
||||||
|
model,
|
||||||
|
year,
|
||||||
|
plateNumber,
|
||||||
|
plateLetters,
|
||||||
|
currentStatus,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional string fields — only include if non-empty
|
||||||
|
if (color) raw.color = color;
|
||||||
|
if (plateType) raw.plateType = plateType;
|
||||||
|
if (registrationNumber) raw.registrationNumber = registrationNumber;
|
||||||
|
if (vinNumber) raw.vinNumber = vinNumber;
|
||||||
|
if (branchId) raw.branchId = branchId;
|
||||||
|
if (insuranceStatus) raw.insuranceStatus = insuranceStatus;
|
||||||
|
if (registrationExpiryDate)
|
||||||
|
raw.registrationExpiryDate = toIsoDateTime(registrationExpiryDate);
|
||||||
|
if (insuranceExpiryDate)
|
||||||
|
raw.insuranceExpiryDate = toIsoDateTime(insuranceExpiryDate);
|
||||||
|
if (inspectionExpiryDate)
|
||||||
|
raw.inspectionExpiryDate = toIsoDateTime(inspectionExpiryDate);
|
||||||
|
|
||||||
|
if (gpsDeviceId) raw.gpsDeviceId = gpsDeviceId;
|
||||||
|
|
||||||
|
// Numeric optional fields
|
||||||
|
if (capacity !== undefined) raw.capacity = capacity;
|
||||||
|
if (weight !== undefined) raw.weight = weight;
|
||||||
|
|
||||||
|
// Cast to CreateCarPayload — structure is identical, we just built it dynamically
|
||||||
|
const payload = raw as unknown as CreateCarPayload;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
setApiError("");
|
||||||
|
const ok = await onSubmit(payload, isNew);
|
||||||
|
setSaving(false);
|
||||||
|
if (ok) onClose();
|
||||||
|
else setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="car-modal-title"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
background: "rgba(15,23,42,0.55)",
|
||||||
|
backdropFilter: "blur(4px)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "1rem",
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 620,
|
||||||
|
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",
|
||||||
|
margin: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isNew ? "إضافة مركبة" : "تعديل مركبة"}
|
||||||
|
</p>
|
||||||
|
<h2
|
||||||
|
id="car-modal-title"
|
||||||
|
style={{
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
margin: "4px 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isNew
|
||||||
|
? "مركبة جديدة"
|
||||||
|
: `${editCar?.manufacturer} ${editCar?.model}`}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="إغلاق"
|
||||||
|
style={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 18,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
noValidate
|
||||||
|
style={{
|
||||||
|
padding: "1.5rem",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "1rem",
|
||||||
|
maxHeight: "75vh",
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
{apiError && (
|
||||||
|
<Alert
|
||||||
|
type="error"
|
||||||
|
message={apiError}
|
||||||
|
onClose={() => setApiError("")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Section: Basic Info */}
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.25em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--color-text-hint)",
|
||||||
|
fontWeight: 700,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
بيانات أساسية
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Manufacturer — directly mapped to CreateCarPayload.manufacturer */}
|
||||||
|
<label style={labelStyle}>
|
||||||
|
الشركة المصنعة *
|
||||||
|
<input
|
||||||
|
ref={firstRef}
|
||||||
|
style={inputStyle("manufacturer")}
|
||||||
|
value={manufacturer}
|
||||||
|
onChange={(e) => {
|
||||||
|
setManufacturer(e.target.value);
|
||||||
|
clearFieldError("manufacturer");
|
||||||
|
}}
|
||||||
|
placeholder="تويوتا"
|
||||||
|
dir="rtl"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
{errors.manufacturer && (
|
||||||
|
<span style={errorTextStyle}>{errors.manufacturer}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Model — directly mapped to CreateCarPayload.model */}
|
||||||
|
<label style={labelStyle}>
|
||||||
|
الموديل *
|
||||||
|
<input
|
||||||
|
style={inputStyle("model")}
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => {
|
||||||
|
setModel(e.target.value);
|
||||||
|
clearFieldError("model");
|
||||||
|
}}
|
||||||
|
placeholder="لاند كروزر"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
{errors.model && (
|
||||||
|
<span style={errorTextStyle}>{errors.model}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr 1fr",
|
||||||
|
gap: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Year — maps to CreateCarPayload.year (number) */}
|
||||||
|
<label style={labelStyle}>
|
||||||
|
سنة الصنع *
|
||||||
|
<input
|
||||||
|
style={inputStyle("year")}
|
||||||
|
type="number"
|
||||||
|
min={1900}
|
||||||
|
max={new Date().getFullYear() + 1}
|
||||||
|
value={year ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
setYear(parseNum(e.target.value));
|
||||||
|
clearFieldError("year");
|
||||||
|
}}
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{errors.year && <span style={errorTextStyle}>{errors.year}</span>}
|
||||||
|
</label>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
اللون
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
value={color}
|
||||||
|
onChange={(e) => setColor(e.target.value)}
|
||||||
|
placeholder="أبيض"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
نوع اللوحة
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
value={plateType}
|
||||||
|
onChange={(e) => setPlateType(e.target.value)}
|
||||||
|
placeholder="خاص"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section: Plate */}
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.25em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--color-text-hint)",
|
||||||
|
fontWeight: 700,
|
||||||
|
margin: "0.5rem 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
بيانات اللوحة
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* plateNumber — directly mapped to CreateCarPayload.plateNumber */}
|
||||||
|
<label style={labelStyle}>
|
||||||
|
رقم اللوحة *
|
||||||
|
<input
|
||||||
|
style={inputStyle("plateNumber")}
|
||||||
|
value={plateNumber}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPlateNumber(e.target.value);
|
||||||
|
clearFieldError("plateNumber");
|
||||||
|
}}
|
||||||
|
placeholder="1234"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
{errors.plateNumber && (
|
||||||
|
<span style={errorTextStyle}>{errors.plateNumber}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* plateLetters — directly mapped to CreateCarPayload.plateLetters */}
|
||||||
|
<label style={labelStyle}>
|
||||||
|
حروف اللوحة *
|
||||||
|
<input
|
||||||
|
style={inputStyle("plateLetters")}
|
||||||
|
value={plateLetters}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPlateLetters(e.target.value);
|
||||||
|
clearFieldError("plateLetters");
|
||||||
|
}}
|
||||||
|
placeholder="أ ب ج"
|
||||||
|
dir="rtl"
|
||||||
|
/>
|
||||||
|
{errors.plateLetters && (
|
||||||
|
<span style={errorTextStyle}>{errors.plateLetters}</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section: Registration & Legal */}
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.25em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--color-text-hint)",
|
||||||
|
fontWeight: 700,
|
||||||
|
margin: "0.5rem 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
الترخيص والتأمين
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr",
|
||||||
|
gap: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
رقم الاستمارة
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
value={registrationNumber}
|
||||||
|
onChange={(e) => setRegistrationNumber(e.target.value)}
|
||||||
|
placeholder="SA-001234"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
رقم الهيكل (VIN)
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
value={vinNumber}
|
||||||
|
onChange={(e) => setVinNumber(e.target.value)}
|
||||||
|
placeholder="1HGBH41JXMN109186"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
انتهاء الاستمارة
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
type="date"
|
||||||
|
value={registrationExpiryDate}
|
||||||
|
onChange={(e) => setRegistrationExpiryDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
حالة التأمين
|
||||||
|
<select
|
||||||
|
style={{ ...inputBase, cursor: "pointer" }}
|
||||||
|
value={insuranceStatus ?? "Valid"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setInsuranceStatus(
|
||||||
|
e.target.value as Car["insuranceStatus"] | undefined,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<option value="Valid">سارٍ</option>
|
||||||
|
<option value="Expired">منتهي</option>
|
||||||
|
<option value="NotInsured">غير مؤمَّن</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
انتهاء التأمين
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
type="date"
|
||||||
|
value={insuranceExpiryDate}
|
||||||
|
onChange={(e) => setInsuranceExpiryDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
انتهاء الفحص الدوري
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
type="date"
|
||||||
|
value={inspectionExpiryDate}
|
||||||
|
onChange={(e) => setInspectionExpiryDate(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section: Operational */}
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.25em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--color-text-hint)",
|
||||||
|
fontWeight: 700,
|
||||||
|
margin: "0.5rem 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
بيانات تشغيلية
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "1fr 1fr 1fr",
|
||||||
|
gap: "0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
الفرع
|
||||||
|
<select
|
||||||
|
style={{ ...inputBase, cursor: "pointer" }}
|
||||||
|
value={branchId}
|
||||||
|
onChange={(e) => setBranchId(e.target.value)}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<option value="">اختر الفرع</option>
|
||||||
|
{branches.map((b) => (
|
||||||
|
<option key={b.id} value={b.id}>
|
||||||
|
{b.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
الحالة
|
||||||
|
<select
|
||||||
|
style={{ ...inputBase, cursor: "pointer" }}
|
||||||
|
value={currentStatus}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCurrentStatus(e.target.value as Car["currentStatus"])
|
||||||
|
}
|
||||||
|
dir="rtl"
|
||||||
|
>
|
||||||
|
<option value="Active">نشط</option>
|
||||||
|
<option value="InMaintenance">صيانة</option>
|
||||||
|
<option value="InTrip">في رحلة</option>
|
||||||
|
<option value="Inactive">غير نشط</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label style={labelStyle}>
|
||||||
|
رقم GPS
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
value={gpsDeviceId}
|
||||||
|
onChange={(e) => setGpsDeviceId(e.target.value)}
|
||||||
|
placeholder="GPS-001"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/* capacity — maps to CreateCarPayload.capacity (number | undefined) */}
|
||||||
|
<label style={labelStyle}>
|
||||||
|
الطاقة الاستيعابية
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={capacity ?? ""}
|
||||||
|
onChange={(e) => setCapacity(parseNum(e.target.value))}
|
||||||
|
placeholder="0"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{/* weight — maps to CreateCarPayload.weight (number | undefined) */}
|
||||||
|
<label style={labelStyle}>
|
||||||
|
الوزن (كجم)
|
||||||
|
<input
|
||||||
|
style={inputBase}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={weight ?? ""}
|
||||||
|
onChange={(e) => setWeight(parseNum(e.target.value))}
|
||||||
|
placeholder="0"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
paddingTop: "0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1.25rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
cursor: saving ? "not-allowed" : "pointer",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إلغاء
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
height: 40,
|
||||||
|
padding: "0 1.5rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "none",
|
||||||
|
background: saving
|
||||||
|
? "var(--color-brand-400)"
|
||||||
|
: "var(--color-brand-600)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#FFF",
|
||||||
|
cursor: saving ? "not-allowed" : "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving && <Spinner size="sm" className="text-white" />}
|
||||||
|
{saving
|
||||||
|
? "جارٍ الحفظ…"
|
||||||
|
: isNew
|
||||||
|
? "إضافة المركبة"
|
||||||
|
: "حفظ التغييرات"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
361
Components/car/CarImageGallery.tsx
Normal file
361
Components/car/CarImageGallery.tsx
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
"use client";
|
||||||
|
// Components/Car/CarImageGallery.tsx
|
||||||
|
// Full-screen gallery modal for viewing, uploading and deleting car images.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Spinner } from "../UI";
|
||||||
|
import { carService } from "../../services/car.service";
|
||||||
|
import { getStoredToken } from "../../lib/auth";
|
||||||
|
import type { Car, CarImage, ImageStage } from "../../types/car";
|
||||||
|
|
||||||
|
// ── Stage badge config ────────────────────────────────────────────────────────
|
||||||
|
const STAGE_MAP: Record<ImageStage, { label: string; color: string; bg: string }> = {
|
||||||
|
BEFORE: { label: "قبل", color: "#854D0E", bg: "#FFFBEB" },
|
||||||
|
AFTER: { label: "بعد", color: "#166534", bg: "#DCFCE7" },
|
||||||
|
GENERAL: { label: "عام", color: "#1E40AF", bg: "#EFF6FF" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Props ─────────────────────────────────────────────────────────────────────
|
||||||
|
interface CarImageGalleryProps {
|
||||||
|
car: Car;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
export function CarImageGallery({ car, onClose }: CarImageGalleryProps) {
|
||||||
|
const [images, setImages] = useState<CarImage[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [lightbox, setLightbox] = useState<CarImage | null>(null);
|
||||||
|
const [stage, setStage] = useState<ImageStage>("GENERAL");
|
||||||
|
const [sortBy, setSortBy] = useState<"asc" | "desc">("desc");
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// ── Fetch images ────────────────────────────────────────────────────────────
|
||||||
|
const fetchImages = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
const res = await carService.getImages(car.id, token, { sortBy });
|
||||||
|
const raw = (res as unknown as { data: CarImage[] }).data ?? [];
|
||||||
|
setImages(raw);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : "تعذّر تحميل الصور");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [car.id, sortBy]);
|
||||||
|
|
||||||
|
useEffect(() => { fetchImages(); }, [fetchImages]);
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
if (lightbox) setLightbox(null);
|
||||||
|
else onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", h);
|
||||||
|
return () => window.removeEventListener("keydown", h);
|
||||||
|
}, [lightbox, onClose]);
|
||||||
|
|
||||||
|
// ── Upload handler ──────────────────────────────────────────────────────────
|
||||||
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(e.target.files ?? []);
|
||||||
|
if (!files.length) return;
|
||||||
|
setUploading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
await carService.uploadImages(car.id, files, stage, token);
|
||||||
|
await fetchImages();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : "فشل رفع الصور");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileRef.current) fileRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Delete handler ──────────────────────────────────────────────────────────
|
||||||
|
const handleDelete = async (imageId: string) => {
|
||||||
|
setDeleting(imageId);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const token = getStoredToken();
|
||||||
|
await carService.deleteImage(imageId, token);
|
||||||
|
setImages(prev => prev.filter(img => img.id !== imageId));
|
||||||
|
if (lightbox?.id === imageId) setLightbox(null);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : "فشل حذف الصورة");
|
||||||
|
} finally {
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
/** Build full image URL — uses the proxy so no CORS issues */
|
||||||
|
const imgSrc = (img: CarImage) =>
|
||||||
|
img.url ?? `/api/proxy/car-photos/${img.image}`;
|
||||||
|
|
||||||
|
// ── Render ───────────────────────────────────────────────────────────────────
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog" aria-modal="true" aria-label="معرض صور المركبة"
|
||||||
|
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 70,
|
||||||
|
background: "rgba(15,23,42,0.7)", backdropFilter: "blur(6px)",
|
||||||
|
display: "flex", flexDirection: "column",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
padding: "1rem 1.5rem",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
|
||||||
|
معرض الصور
|
||||||
|
</p>
|
||||||
|
<h2 style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: "2px 0 0" }}>
|
||||||
|
{car.manufacturer} {car.model} — {car.plateLetters} {car.plateNumber}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||||
|
{/* Sort */}
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={e => setSortBy(e.target.value as "asc" | "desc")}
|
||||||
|
style={{
|
||||||
|
height: 36, borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 12, color: "var(--color-text-secondary)",
|
||||||
|
padding: "0 0.75rem", outline: "none",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="desc">الأحدث أولاً</option>
|
||||||
|
<option value="asc">الأقدم أولاً</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Stage selector for upload */}
|
||||||
|
<select
|
||||||
|
value={stage}
|
||||||
|
onChange={e => setStage(e.target.value as ImageStage)}
|
||||||
|
style={{
|
||||||
|
height: 36, borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 12, color: "var(--color-text-secondary)",
|
||||||
|
padding: "0 0.75rem", outline: "none",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="GENERAL">عام</option>
|
||||||
|
<option value="BEFORE">قبل</option>
|
||||||
|
<option value="AFTER">بعد</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Upload button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
style={{
|
||||||
|
height: 36, padding: "0 1rem",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "none",
|
||||||
|
background: uploading ? "var(--color-brand-400)" : "var(--color-brand-600)",
|
||||||
|
fontSize: 13, fontWeight: 700, color: "#FFF",
|
||||||
|
cursor: uploading ? "not-allowed" : "pointer",
|
||||||
|
display: "flex", alignItems: "center", gap: 8,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{uploading
|
||||||
|
? <><Spinner size="sm" className="text-white" /> جارٍ الرفع…</>
|
||||||
|
: <>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
رفع صور
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={handleUpload}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Close */}
|
||||||
|
<button type="button" onClick={onClose} aria-label="إغلاق"
|
||||||
|
style={{ width: 36, height: 36, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", cursor: "pointer", fontSize: 20, color: "var(--color-text-muted)", display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Error ── */}
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: "0.75rem 1.5rem", background: "#FEF2F2", borderBottom: "1px solid #FECACA", fontSize: 13, color: "#DC2626", fontWeight: 500 }}>
|
||||||
|
⚠ {error}
|
||||||
|
<button onClick={() => setError(null)} style={{ marginRight: 8, fontSize: 14, background: "none", border: "none", color: "#DC2626", cursor: "pointer" }}>×</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Gallery Grid ── */}
|
||||||
|
<div style={{ flex: 1, overflowY: "auto", padding: "1.5rem" }}>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0" }}>
|
||||||
|
<Spinner size="lg" />
|
||||||
|
<span style={{ fontSize: 14, color: "var(--color-text-muted)" }}>جارٍ تحميل الصور…</span>
|
||||||
|
</div>
|
||||||
|
) : images.length === 0 ? (
|
||||||
|
<div style={{ textAlign: "center", padding: "5rem 0" }}>
|
||||||
|
<div style={{ fontSize: 48, marginBottom: 12 }}>📸</div>
|
||||||
|
<p style={{ fontSize: 15, color: "var(--color-text-muted)", fontWeight: 600 }}>لا توجد صور بعد</p>
|
||||||
|
<p style={{ fontSize: 13, color: "var(--color-text-hint)", marginTop: 4 }}>
|
||||||
|
اضغط على "رفع صور" لإضافة أولى الصور لهذه المركبة.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||||
|
gap: "1rem",
|
||||||
|
}}>
|
||||||
|
{images.map(img => {
|
||||||
|
const stageInfo = STAGE_MAP[img.stage ?? "GENERAL"] ?? STAGE_MAP.GENERAL;
|
||||||
|
const isDeleting = deleting === img.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={img.id}
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
opacity: isDeleting ? 0.5 : 1,
|
||||||
|
transition: "opacity 200ms",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Image */}
|
||||||
|
<div
|
||||||
|
style={{ position: "relative", paddingBottom: "70%", cursor: "pointer" }}
|
||||||
|
onClick={() => setLightbox(img)}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={imgSrc(img)}
|
||||||
|
alt={`صورة ${stageInfo.label}`}
|
||||||
|
style={{
|
||||||
|
position: "absolute", inset: 0,
|
||||||
|
width: "100%", height: "100%",
|
||||||
|
objectFit: "cover",
|
||||||
|
}}
|
||||||
|
onError={e => { (e.target as HTMLImageElement).src = "/file.svg"; }}
|
||||||
|
/>
|
||||||
|
{/* Stage badge */}
|
||||||
|
<span style={{
|
||||||
|
position: "absolute", top: 8, right: 8,
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
background: stageInfo.bg,
|
||||||
|
color: stageInfo.color,
|
||||||
|
padding: "0.2rem 0.625rem",
|
||||||
|
fontSize: 10, fontWeight: 700,
|
||||||
|
boxShadow: "0 1px 4px rgba(0,0,0,.12)",
|
||||||
|
}}>
|
||||||
|
{stageInfo.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
padding: "0.6rem 0.75rem",
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
|
{new Date(img.createdAt).toLocaleDateString("ar-SA", { month: "short", day: "numeric" })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(img.id)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
aria-label="حذف الصورة"
|
||||||
|
style={{
|
||||||
|
width: 28, height: 28,
|
||||||
|
borderRadius: "var(--radius-sm)",
|
||||||
|
border: "1px solid #FECACA",
|
||||||
|
background: "#FEF2F2",
|
||||||
|
color: "#DC2626",
|
||||||
|
cursor: isDeleting ? "not-allowed" : "pointer",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isDeleting
|
||||||
|
? <Spinner size="sm" className="text-red-600" />
|
||||||
|
: <svg width="12" height="12" 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" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Lightbox ── */}
|
||||||
|
{lightbox && (
|
||||||
|
<div
|
||||||
|
onClick={() => setLightbox(null)}
|
||||||
|
style={{
|
||||||
|
position: "fixed", inset: 0, zIndex: 80,
|
||||||
|
background: "rgba(0,0,0,0.9)",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
padding: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={imgSrc(lightbox)}
|
||||||
|
alt="عرض مكبَّر"
|
||||||
|
style={{ maxWidth: "90vw", maxHeight: "90vh", borderRadius: "var(--radius-lg)", objectFit: "contain" }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
onError={e => { (e.target as HTMLImageElement).src = "/file.svg"; }}
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { getStoredToken } from "../../lib/auth";
|
import { getStoredToken } from "../../lib/auth";
|
||||||
|
import { carService } from "../../services/car.service";
|
||||||
import { get } from "../../services/api";
|
import { get } from "../../services/api";
|
||||||
import { Spinner, Alert } from "../../Components/UI";
|
import { Spinner, Alert } from "../../Components/UI";
|
||||||
|
import { CarFormModal } from "../../Components/car/CarFormModal";
|
||||||
|
import { CarDetailPanel } from "../../Components/car/CarDetailPanel";
|
||||||
|
import { CarDeleteModal } from "../../Components/car/CarDeleteModal";
|
||||||
|
import type { Car, CreateCarPayload, UpdateCarPayload } from "../../types/car";
|
||||||
|
|
||||||
interface Car {
|
// ── API response shape ────────────────────────────────────────────────────────
|
||||||
id: string;
|
|
||||||
manufacturer: string;
|
|
||||||
model: string;
|
|
||||||
year: number;
|
|
||||||
color?: string;
|
|
||||||
plateNumber: string;
|
|
||||||
plateLetters: string;
|
|
||||||
currentStatus: "Active" | "InMaintenance" | "InTrip" | "Inactive";
|
|
||||||
insuranceStatus?: "Valid" | "Expired" | "NotInsured";
|
|
||||||
insuranceExpiryDate?: string;
|
|
||||||
registrationExpiryDate?: string;
|
|
||||||
branch?: { name: string };
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiResponse {
|
interface ApiResponse {
|
||||||
data: {
|
data: {
|
||||||
data: Car[];
|
data: Car[];
|
||||||
@@ -29,365 +19,473 @@ interface ApiResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Status badge config ───────────────────────────────────────────────────────
|
||||||
const STATUS_MAP: Record<
|
const STATUS_MAP: Record<
|
||||||
Car["currentStatus"],
|
Car["currentStatus"],
|
||||||
{ label: string; color: string; bg: string; border: string }
|
{ label: string; color: string; bg: string; border: string; dot: string }
|
||||||
> = {
|
> = {
|
||||||
Active: { label: "نشط", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0" },
|
Active: { label: "نشط", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0", dot: "#16A34A" },
|
||||||
InMaintenance: {
|
InMaintenance: { label: "صيانة", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" },
|
||||||
label: "صيانة",
|
InTrip: { label: "في رحلة", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE", dot: "#3B82F6" },
|
||||||
color: "#854D0E",
|
Inactive: { label: "غير نشط", color: "#64748B", bg: "#F1F5F9", border: "#E2E8F0", dot: "#94A3B8" },
|
||||||
bg: "#FFFBEB",
|
|
||||||
border: "#FDE68A",
|
|
||||||
},
|
|
||||||
InTrip: {
|
|
||||||
label: "في رحلة",
|
|
||||||
color: "#1E40AF",
|
|
||||||
bg: "#EFF6FF",
|
|
||||||
border: "#BFDBFE",
|
|
||||||
},
|
|
||||||
Inactive: {
|
|
||||||
label: "غير نشط",
|
|
||||||
color: "#64748B",
|
|
||||||
bg: "#F1F5F9",
|
|
||||||
border: "#E2E8F0",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const INS_MAP: Record<string, { label: string; color: string }> = {
|
const INS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
Valid: { label: "سارٍ", color: "#16A34A" },
|
Valid: { label: "سارٍ", color: "#16A34A" },
|
||||||
Expired: { label: "منتهي", color: "#DC2626" },
|
Expired: { label: "منتهي", color: "#DC2626" },
|
||||||
NotInsured: { label: "غير مؤمَّن", color: "#D97706" },
|
NotInsured: { label: "غير مؤمَّن", color: "#D97706" },
|
||||||
};
|
};
|
||||||
|
|
||||||
function fmtDate(iso?: string) {
|
function fmtDate(iso?: string) {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" });
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cardStyle: React.CSSProperties = {
|
function isExpiringSoon(iso?: string) {
|
||||||
borderRadius: "var(--radius-xl)",
|
if (!iso) return false;
|
||||||
border: "1px solid var(--color-border)",
|
return (new Date(iso).getTime() - Date.now()) / 86_400_000 <= 90;
|
||||||
background: "var(--color-surface)",
|
}
|
||||||
overflow: "hidden",
|
|
||||||
boxShadow: "var(--shadow-card)",
|
|
||||||
};
|
|
||||||
|
|
||||||
const thStyle: React.CSSProperties = {
|
// ── Toast (inline — mirrors Components/User/Toast pattern) ───────────────────
|
||||||
padding: "0.75rem 1.5rem",
|
interface ToastMsg { type: "success" | "error"; message: string }
|
||||||
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)",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CarsPage() {
|
|
||||||
const [cars, setCars] = useState<Car[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [total, setTotal] = useState(0);
|
|
||||||
const [pages, setPages] = useState(1);
|
|
||||||
|
|
||||||
|
function CarToast({ notification }: { notification: ToastMsg | null }) {
|
||||||
|
const [vis, setVis] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (notification) setVis(true);
|
||||||
|
else { const t = setTimeout(() => setVis(false), 300); return () => clearTimeout(t); }
|
||||||
|
}, [notification]);
|
||||||
|
if (!vis && !notification) return null;
|
||||||
|
const ok = notification?.type === "success";
|
||||||
|
return (
|
||||||
|
<div role="status" aria-live="polite" style={{
|
||||||
|
position: "fixed", bottom: 24, left: "50%",
|
||||||
|
transform: `translateX(-50%) translateY(${notification ? "0" : "16px"})`,
|
||||||
|
zIndex: 9999, transition: "transform 250ms ease, opacity 250ms ease",
|
||||||
|
opacity: notification ? 1 : 0, pointerEvents: "none",
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 10,
|
||||||
|
padding: "0.75rem 1.25rem",
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
background: ok ? "#065F46" : "#7F1D1D",
|
||||||
|
color: "#FFF", fontSize: 13, fontWeight: 600,
|
||||||
|
boxShadow: "0 8px 32px rgba(0,0,0,.25)",
|
||||||
|
maxWidth: "90vw", whiteSpace: "nowrap",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 16 }}>{ok ? "✓" : "⚠"}</span>
|
||||||
|
<span>{notification?.message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vehicle Card component ────────────────────────────────────────────────────
|
||||||
|
function CarCard({ car, onClick }: { car: Car; onClick: () => void }) {
|
||||||
|
const status = STATUS_MAP[car.currentStatus];
|
||||||
|
const ins = car.insuranceStatus ? INS_MAP[car.insuranceStatus] : null;
|
||||||
|
const regWarn = isExpiringSoon(car.registrationExpiryDate as string | undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
onClick={onClick}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={e => { if (e.key === "Enter" || e.key === " ") onClick(); }}
|
||||||
|
aria-label={`${car.manufacturer} ${car.model} — ${car.plateLetters} ${car.plateNumber}`}
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--radius-xl)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
overflow: "hidden",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "box-shadow 200ms, transform 200ms",
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => {
|
||||||
|
(e.currentTarget as HTMLElement).style.boxShadow = "0 8px 24px rgba(37,99,235,.12)";
|
||||||
|
(e.currentTarget as HTMLElement).style.transform = "translateY(-2px)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={e => {
|
||||||
|
(e.currentTarget as HTMLElement).style.boxShadow = "var(--shadow-card)";
|
||||||
|
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Colour accent bar by status */}
|
||||||
|
<div style={{
|
||||||
|
height: 4,
|
||||||
|
background: status.dot,
|
||||||
|
borderRadius: "var(--radius-xl) var(--radius-xl) 0 0",
|
||||||
|
}} />
|
||||||
|
|
||||||
|
<div style={{ padding: "1.25rem" }}>
|
||||||
|
{/* Manufacturer + model */}
|
||||||
|
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8 }}>
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
|
{car.manufacturer} {car.model}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 12, color: "var(--color-text-muted)", marginTop: 2 }}>
|
||||||
|
{car.year}{car.color ? ` · ${car.color}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* Status badge */}
|
||||||
|
<span style={{
|
||||||
|
display: "inline-flex", alignItems: "center", gap: 5,
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
border: `1px solid ${status.border}`,
|
||||||
|
background: status.bg,
|
||||||
|
padding: "0.2rem 0.625rem",
|
||||||
|
fontSize: 11, fontWeight: 700, color: status.color,
|
||||||
|
whiteSpace: "nowrap", flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: "50%", background: status.dot }} />
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plate number — prominent */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: "0.875rem",
|
||||||
|
background: "var(--color-surface-muted)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)", fontWeight: 600 }}>رقم اللوحة</span>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 14, fontWeight: 700, letterSpacing: "0.1em",
|
||||||
|
color: "var(--color-brand-700)",
|
||||||
|
}}>
|
||||||
|
{car.plateLetters} {car.plateNumber}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key attributes grid */}
|
||||||
|
<div style={{ marginTop: "0.875rem", display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.5rem" }}>
|
||||||
|
{/* Branch */}
|
||||||
|
<div style={{ fontSize: 11 }}>
|
||||||
|
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>الفرع</span>
|
||||||
|
<span style={{ color: "var(--color-text-primary)", marginTop: 2, display: "block" }}>
|
||||||
|
{car.branch?.name ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Insurance */}
|
||||||
|
<div style={{ fontSize: 11 }}>
|
||||||
|
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>التأمين</span>
|
||||||
|
<span style={{
|
||||||
|
color: ins?.color ?? "var(--color-text-muted)",
|
||||||
|
marginTop: 2, display: "block", fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{ins?.label ?? "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Registration expiry */}
|
||||||
|
<div style={{ fontSize: 11 }}>
|
||||||
|
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>انتهاء الاستمارة</span>
|
||||||
|
<span style={{
|
||||||
|
color: regWarn ? "#D97706" : "var(--color-text-secondary)",
|
||||||
|
marginTop: 2, display: "block", fontWeight: regWarn ? 600 : 400,
|
||||||
|
}}>
|
||||||
|
{regWarn && "⚠ "}{fmtDate(car.registrationExpiryDate as string | undefined)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Capacity */}
|
||||||
|
<div style={{ fontSize: 11 }}>
|
||||||
|
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>الطاقة</span>
|
||||||
|
<span style={{ color: "var(--color-text-secondary)", marginTop: 2, display: "block" }}>
|
||||||
|
{car.capacity != null ? car.capacity : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer CTA hint */}
|
||||||
|
<p style={{
|
||||||
|
marginTop: "0.875rem",
|
||||||
|
fontSize: 11, color: "var(--color-brand-600)",
|
||||||
|
fontWeight: 600, textAlign: "left",
|
||||||
|
}}>
|
||||||
|
اضغط لعرض التفاصيل ←
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||||
|
export default function CarsPage() {
|
||||||
|
// List state
|
||||||
|
const [cars, setCars] = useState<Car[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [pages, setPages] = useState(1);
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [detailId, setDetailId] = useState<string | null>(null); // CarDetailPanel
|
||||||
|
const [formTarget, setFormTarget] = useState<Car | null | false>(false); // false=closed
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Car | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// Toast
|
||||||
|
const [toast, setToast] = useState<{ type: "success" | "error"; message: string } | null>(null);
|
||||||
|
const notify = useCallback((t: typeof toast) => {
|
||||||
|
setToast(t);
|
||||||
|
setTimeout(() => setToast(null), 3500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Fetch list ─────────────────────────────────────────────────────────────
|
||||||
|
const loadCars = useCallback(() => {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const query = `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
const query = `?page=${page}&limit=12${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||||
get<ApiResponse>(`cars${query}`, token)
|
get<ApiResponse>(`cars${query}`, token)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const payload = res.data ?? res;
|
const payload = (res as unknown as { data: ApiResponse["data"] }).data ?? res;
|
||||||
setCars(payload.data ?? []);
|
setCars((payload as ApiResponse["data"]).data ?? []);
|
||||||
setTotal(payload.meta?.total ?? 0);
|
setTotal((payload as ApiResponse["data"]).meta?.total ?? 0);
|
||||||
setPages(payload.meta?.pages ?? 1);
|
setPages((payload as ApiResponse["data"]).meta?.pages ?? 1);
|
||||||
})
|
})
|
||||||
.catch((err: Error) => setError(err.message))
|
.catch((err: Error) => setError(err.message))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [page, search]);
|
}, [page, search]);
|
||||||
|
|
||||||
|
useEffect(() => { loadCars(); }, [loadCars]);
|
||||||
|
|
||||||
|
// ── Create / Update ────────────────────────────────────────────────────────
|
||||||
|
const handleFormSubmit = async (
|
||||||
|
payload: CreateCarPayload | UpdateCarPayload,
|
||||||
|
isNew: boolean,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const token = getStoredToken();
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
await carService.create(payload as CreateCarPayload, token);
|
||||||
|
notify({ type: "success", message: "تم إضافة المركبة بنجاح." });
|
||||||
|
} else {
|
||||||
|
await carService.update((formTarget as Car).id, payload as UpdateCarPayload, token);
|
||||||
|
notify({ type: "success", message: "تم تحديث بيانات المركبة." });
|
||||||
|
}
|
||||||
|
loadCars();
|
||||||
|
return true;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
notify({ type: "error", message: err instanceof Error ? err.message : "فشلت العملية." });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Delete ─────────────────────────────────────────────────────────────────
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
const token = getStoredToken();
|
||||||
|
try {
|
||||||
|
await carService.delete(deleteTarget.id, token);
|
||||||
|
// Optimistic removal — no page refresh needed
|
||||||
|
setCars(prev => prev.filter(c => c.id !== deleteTarget.id));
|
||||||
|
setTotal(prev => Math.max(0, prev - 1));
|
||||||
|
setDeleteTarget(null);
|
||||||
|
notify({ type: "success", message: `تم حذف ${deleteTarget.manufacturer} ${deleteTarget.model} بنجاح.` });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
notify({ type: "error", message: err instanceof Error ? err.message : "فشل الحذف." });
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<section
|
<>
|
||||||
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
|
{/* Toast notification */}
|
||||||
>
|
<CarToast notification={toast} />
|
||||||
{/* Header */}
|
|
||||||
<header
|
{/* Detail panel (slide-in) */}
|
||||||
style={{
|
{detailId && (
|
||||||
|
<CarDetailPanel
|
||||||
|
carId={detailId}
|
||||||
|
onClose={() => setDetailId(null)}
|
||||||
|
onEdit={(car) => { setDetailId(null); setFormTarget(car); }}
|
||||||
|
onDelete={(car) => { setDetailId(null); setDeleteTarget(car); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create / Edit modal */}
|
||||||
|
{formTarget !== false && (
|
||||||
|
<CarFormModal
|
||||||
|
editCar={formTarget}
|
||||||
|
branches={[]} // loaded inside modal via its own effect in production
|
||||||
|
onClose={() => setFormTarget(false)}
|
||||||
|
onSubmit={handleFormSubmit}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete confirmation */}
|
||||||
|
{deleteTarget && (
|
||||||
|
<CarDeleteModal
|
||||||
|
car={deleteTarget}
|
||||||
|
deleting={deleting}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }} dir="rtl">
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<header style={{
|
||||||
borderRadius: "var(--radius-xl)",
|
borderRadius: "var(--radius-xl)",
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
background: "var(--color-surface)",
|
background: "var(--color-surface)",
|
||||||
padding: "1.5rem 2rem",
|
padding: "1.5rem 2rem",
|
||||||
boxShadow: "var(--shadow-card)",
|
boxShadow: "var(--shadow-card)",
|
||||||
}}
|
}}>
|
||||||
>
|
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
|
||||||
<p
|
إدارة الأسطول
|
||||||
style={{
|
</p>
|
||||||
fontSize: 11,
|
<div style={{ marginTop: "0.75rem", display: "flex", flexWrap: "wrap", gap: "1rem", alignItems: "flex-end", justifyContent: "space-between" }}>
|
||||||
letterSpacing: "0.3em",
|
<div>
|
||||||
textTransform: "uppercase",
|
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||||
color: "#2563EB",
|
المركبات
|
||||||
fontWeight: 600,
|
</h1>
|
||||||
}}
|
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||||
>
|
إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{total}</strong> مركبة في الأسطول
|
||||||
إدارة الأسطول
|
</p>
|
||||||
</p>
|
</div>
|
||||||
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
|
||||||
<div>
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
|
||||||
<h1
|
{/* Search */}
|
||||||
style={{
|
<div style={{ position: "relative", width: 256 }}>
|
||||||
fontSize: "1.5rem",
|
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
|
||||||
fontWeight: 700,
|
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||||
color: "var(--color-text-primary)",
|
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||||
margin: 0,
|
</svg>
|
||||||
}}
|
<input
|
||||||
>
|
type="text"
|
||||||
السيارات
|
placeholder="بحث بالماركة أو اللوحة..."
|
||||||
</h1>
|
value={search}
|
||||||
<p
|
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||||
style={{
|
dir="rtl"
|
||||||
marginTop: "0.25rem",
|
style={{
|
||||||
fontSize: 13,
|
width: "100%", height: 40, paddingRight: 36, paddingLeft: 12,
|
||||||
color: "var(--color-text-muted)",
|
borderRadius: "var(--radius-lg)",
|
||||||
}}
|
border: "1px solid var(--color-border)",
|
||||||
>
|
background: "var(--color-surface)",
|
||||||
إجمالي{" "}
|
fontSize: 13, color: "var(--color-text-primary)",
|
||||||
<strong style={{ color: "var(--color-text-primary)" }}>
|
outline: "none", fontFamily: "var(--font-sans)",
|
||||||
{total}
|
}}
|
||||||
</strong>{" "}
|
/>
|
||||||
مركبة في الأسطول
|
</div>
|
||||||
</p>
|
|
||||||
|
{/* Add button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormTarget(null)}
|
||||||
|
style={{
|
||||||
|
height: 40, padding: "0 1.125rem",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "none",
|
||||||
|
background: "var(--color-brand-600)",
|
||||||
|
fontSize: 13, fontWeight: 700, color: "#FFF",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex", alignItems: "center", gap: 7,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
إضافة مركبة
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ position: "relative", width: "100%", maxWidth: 288 }}>
|
</header>
|
||||||
<svg
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
right: 12,
|
|
||||||
top: "50%",
|
|
||||||
transform: "translateY(-50%)",
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
color: "var(--color-text-hint)",
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle cx="11" cy="11" r="8" />
|
|
||||||
<path d="m21 21-4.35-4.35" />
|
|
||||||
</svg>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="بحث بالماركة أو اللوحة..."
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearch(e.target.value);
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
dir="rtl"
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: 40,
|
|
||||||
paddingRight: 36,
|
|
||||||
paddingLeft: 12,
|
|
||||||
borderRadius: "var(--radius-lg)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-surface)",
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
outline: "none",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{error && (
|
{/* Error alert */}
|
||||||
<Alert type="error" message={error} onClose={() => setError(null)} />
|
{error && <Alert type="error" message={error} onClose={() => setError(null)} />}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<div style={cardStyle}>
|
|
||||||
<div
|
|
||||||
dir="rtl"
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 1fr",
|
|
||||||
...thStyle,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>السيارة</span>
|
|
||||||
<span>اللوحة</span>
|
|
||||||
<span>الفرع</span>
|
|
||||||
<span>الحالة</span>
|
|
||||||
<span>التأمين</span>
|
|
||||||
<span style={{ textAlign: "center" }}>انتهاء الاستمارة</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
{/* ── Loading ── */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "5rem 0", color: "var(--color-text-muted)" }}>
|
||||||
style={{
|
<Spinner size="md" className="text-blue-600" />
|
||||||
display: "flex",
|
<span style={{ fontSize: 14 }}>جارٍ تحميل المركبات…</span>
|
||||||
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>
|
</div>
|
||||||
) : cars.length === 0 ? (
|
) : cars.length === 0 ? (
|
||||||
<p
|
/* Empty state */
|
||||||
style={{
|
<div style={{
|
||||||
textAlign: "center",
|
borderRadius: "var(--radius-xl)",
|
||||||
padding: "4rem 0",
|
border: "2px dashed var(--color-border)",
|
||||||
fontSize: 13,
|
background: "var(--color-surface)",
|
||||||
color: "var(--color-text-muted)",
|
padding: "5rem 2rem",
|
||||||
}}
|
textAlign: "center",
|
||||||
>
|
}}>
|
||||||
لا توجد نتائج {search && `لـ "${search}"`}
|
<div style={{ fontSize: 52, marginBottom: 16 }}>🚗</div>
|
||||||
</p>
|
<p style={{ fontSize: 16, fontWeight: 600, color: "var(--color-text-primary)" }}>
|
||||||
|
{search ? `لا توجد مركبات تطابق "${search}"` : "لا توجد مركبات بعد"}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 13, color: "var(--color-text-muted)", marginTop: 6 }}>
|
||||||
|
اضغط على "إضافة مركبة" لإضافة أول مركبة في الأسطول.
|
||||||
|
</p>
|
||||||
|
{!search && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormTarget(null)}
|
||||||
|
style={{
|
||||||
|
marginTop: 16, height: 40, padding: "0 1.5rem",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "none", background: "var(--color-brand-600)",
|
||||||
|
fontSize: 13, fontWeight: 700, color: "#FFF",
|
||||||
|
cursor: "pointer", fontFamily: "var(--font-sans)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
إضافة مركبة
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
/* ── Card grid ── */
|
||||||
{cars.map((c, i) => {
|
<div style={{
|
||||||
const status = STATUS_MAP[c.currentStatus];
|
display: "grid",
|
||||||
const ins = c.insuranceStatus ? INS_MAP[c.insuranceStatus] : null;
|
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
|
||||||
return (
|
gap: "1rem",
|
||||||
<li
|
}}>
|
||||||
key={c.id}
|
{cars.map((car) => (
|
||||||
style={{
|
<CarCard
|
||||||
display: "grid",
|
key={car.id}
|
||||||
gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 1fr",
|
car={car}
|
||||||
alignItems: "center",
|
onClick={() => setDetailId(car.id)}
|
||||||
gap: "0.5rem",
|
/>
|
||||||
padding: "0.875rem 1.5rem",
|
))}
|
||||||
borderBottom: "1px solid var(--color-border)",
|
</div>
|
||||||
background:
|
|
||||||
i % 2 !== 0
|
|
||||||
? "var(--color-surface-muted)"
|
|
||||||
: "transparent",
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{c.manufacturer} {c.model}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
marginTop: 2,
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{c.year}
|
|
||||||
{c.color ? ` · ${c.color}` : ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--font-mono)",
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: "0.08em",
|
|
||||||
color: "#2563EB",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{c.plateLetters} {c.plateNumber}
|
|
||||||
</span>
|
|
||||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
|
||||||
{c.branch?.name ?? "—"}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
borderRadius: "var(--radius-full)",
|
|
||||||
border: `1px solid ${status.border}`,
|
|
||||||
background: status.bg,
|
|
||||||
padding: "0.2rem 0.625rem",
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: status.color,
|
|
||||||
width: "fit-content",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{status.label}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: ins?.color ?? "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ins?.label ?? "—"}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{fmtDate(c.registrationExpiryDate)}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Pagination ── */}
|
||||||
{pages > 1 && (
|
{pages > 1 && (
|
||||||
<div
|
<div style={{
|
||||||
dir="rtl"
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||||
style={{
|
borderRadius: "var(--radius-xl)",
|
||||||
display: "flex",
|
border: "1px solid var(--color-border)",
|
||||||
alignItems: "center",
|
background: "var(--color-surface)",
|
||||||
justifyContent: "space-between",
|
padding: "0.875rem 1.5rem",
|
||||||
borderTop: "1px solid var(--color-border)",
|
boxShadow: "var(--shadow-card)",
|
||||||
padding: "0.875rem 1.5rem",
|
}}>
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
<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)" }}>
|
<strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
||||||
{page}
|
{" · "}
|
||||||
</strong>{" "}
|
<span style={{ color: "var(--color-text-hint)" }}>{total} مركبة</span>
|
||||||
من{" "}
|
|
||||||
<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" }}>
|
||||||
{[
|
{[
|
||||||
{
|
{ label: "← السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
|
||||||
label: "السابق",
|
{ label: "التالي →", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages },
|
||||||
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) => (
|
].map((btn) => (
|
||||||
<button
|
<button
|
||||||
key={btn.label}
|
key={btn.label}
|
||||||
@@ -398,11 +496,11 @@ export default function CarsPage() {
|
|||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
background: "var(--color-surface-muted)",
|
background: "var(--color-surface-muted)",
|
||||||
padding: "0.375rem 0.875rem",
|
padding: "0.375rem 0.875rem",
|
||||||
fontSize: 12,
|
fontSize: 12, color: "var(--color-text-secondary)",
|
||||||
color: "var(--color-text-secondary)",
|
|
||||||
cursor: btn.disabled ? "not-allowed" : "pointer",
|
cursor: btn.disabled ? "not-allowed" : "pointer",
|
||||||
opacity: btn.disabled ? 0.4 : 1,
|
opacity: btn.disabled ? 0.4 : 1,
|
||||||
fontFamily: "var(--font-sans)",
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontWeight: 600,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{btn.label}
|
{btn.label}
|
||||||
@@ -411,7 +509,7 @@ export default function CarsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
</section>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
// صفحة إدارة المستخدمين — تستورد فقط من الطبقات الأخرى
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert } from "../../Components/UI";
|
import { Alert } from "../../Components/UI";
|
||||||
import { UserTable } from "../../Components/User/UserTable";
|
import { UserTable } from "../../Components/User/UserTable";
|
||||||
import { UserFormModal } from "../../Components/User/UserFormModal";
|
import { UserFormModal } from "../../Components/User/UserFormModal";
|
||||||
import { DeleteConfirmModal } from "../../Components/User/DeleteConfirmModal";
|
import { DeleteConfirmModal } from "../../Components/User/DeleteConfirmModal";
|
||||||
import { Toast } from "../../Components/User/Toast";
|
import { Toast } from "../../Components/User/Toast";
|
||||||
import { useUsers } from "../../hooks/useUser";
|
import { useUsers } from "../../hooks/useUser";
|
||||||
import type { User, UserFormData } from "../../types/user";
|
import type { User, UserFormData } from "../../types/user";
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
// ── الحالة المحلية للمودالات فقط ─────────────────────────────────────────
|
// ── Modal state ─────────────────────────────────────────────────────────────
|
||||||
// false = مغلق، null = وضع الإضافة، User = وضع التعديل
|
// false = closed | null = create mode | User = edit mode
|
||||||
const [formTarget, setFormTarget] = useState<User | null | false>(false);
|
const [formTarget, setFormTarget] = useState<User | null | false>(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
||||||
const [deleting, setDeleting] = useState(false);
|
// Local submitting flag shown in DeleteConfirmModal spinner
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
// ── كل منطق البيانات والحالة من الهوك ────────────────────────────────────
|
// ── Data hook ───────────────────────────────────────────────────────────────
|
||||||
const {
|
const {
|
||||||
users, loading, total, pages, error,
|
users, loading, total, pages, error,
|
||||||
roles, branches,
|
roles, branches,
|
||||||
@@ -27,27 +27,41 @@ export default function UsersPage() {
|
|||||||
notification,
|
notification,
|
||||||
} = useUsers();
|
} = useUsers();
|
||||||
|
|
||||||
// ── معالج تقديم النموذج (إضافة أو تعديل) ────────────────────────────────
|
// ── Create / Update handler ─────────────────────────────────────────────────
|
||||||
const handleFormSubmit = async (data: UserFormData, isNew: boolean): Promise<boolean> => {
|
const handleFormSubmit = async (data: UserFormData, isNew: boolean): Promise<boolean> => {
|
||||||
if (isNew) return createUser(data);
|
if (isNew) return createUser(data);
|
||||||
|
// formTarget is User when editing
|
||||||
return updateUser((formTarget as User).id, data);
|
return updateUser((formTarget as User).id, data);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── معالج تأكيد الحذف ────────────────────────────────────────────────────
|
// ── Delete handler ──────────────────────────────────────────────────────────
|
||||||
|
// FIX: We guard against double-click with local `deleting` flag,
|
||||||
|
// then call deleteUser which:
|
||||||
|
// 1. Calls the API
|
||||||
|
// 2. Dispatches { type: "DELETE", id } → reducer removes user from array
|
||||||
|
// 3. Calls notify() → Toast appears immediately
|
||||||
|
// Result: the row disappears from the table and a success toast shows — all
|
||||||
|
// without a page refresh.
|
||||||
const handleDeleteConfirm = async () => {
|
const handleDeleteConfirm = async () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget || deleting) return;
|
||||||
setDeleting(true);
|
setDeleting(true);
|
||||||
const ok = await deleteUser(deleteTarget.id);
|
const ok = await deleteUser(deleteTarget.id);
|
||||||
|
console.log("Delete result:", ok);
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
if (ok) setDeleteTarget(null);
|
if (ok) {
|
||||||
|
// Close modal only on success; on failure the user sees the error toast
|
||||||
|
// and can retry or cancel manually.
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Render ──────────────────────────────────────────────────────────────────
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── Toast لعرض رسائل النجاح والخطأ ── */}
|
{/* Global success / error toast — fires on create, update, AND delete */}
|
||||||
<Toast notification={notification} />
|
<Toast notification={notification} />
|
||||||
|
|
||||||
{/* ── مودال الإضافة / التعديل ── */}
|
{/* Create / Edit modal */}
|
||||||
{formTarget !== false && (
|
{formTarget !== false && (
|
||||||
<UserFormModal
|
<UserFormModal
|
||||||
editUser={formTarget}
|
editUser={formTarget}
|
||||||
@@ -58,22 +72,28 @@ export default function UsersPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── مودال تأكيد الحذف ── */}
|
{/* Delete confirmation dialog */}
|
||||||
{deleteTarget && (
|
{deleteTarget && (
|
||||||
<DeleteConfirmModal
|
<DeleteConfirmModal
|
||||||
user={deleteTarget}
|
user={deleteTarget}
|
||||||
deleting={deleting}
|
deleting={deleting}
|
||||||
onCancel={() => setDeleteTarget(null)}
|
onCancel={() => {
|
||||||
|
// Only allow cancel when not mid-flight
|
||||||
|
if (!deleting) setDeleteTarget(null);
|
||||||
|
}}
|
||||||
onConfirm={handleDeleteConfirm}
|
onConfirm={handleDeleteConfirm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||||
|
|
||||||
{/* ── رأس الصفحة ── */}
|
{/* ── Page header ── */}
|
||||||
<header style={{
|
<header style={{
|
||||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
borderRadius: "var(--radius-xl)",
|
||||||
background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)",
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
padding: "1.5rem 2rem",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
}}>
|
}}>
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
|
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
|
||||||
إدارة الفريق
|
إدارة الفريق
|
||||||
@@ -88,25 +108,53 @@ export default function UsersPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* أدوات البحث وإضافة مستخدم */}
|
|
||||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
|
||||||
{/* حقل البحث */}
|
{/* Search input */}
|
||||||
<div style={{ position: "relative", width: 256 }}>
|
<div style={{ position: "relative", width: 256 }}>
|
||||||
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
|
<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">
|
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" />
|
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||||
</svg>
|
</svg>
|
||||||
<input type="text" placeholder="بحث بالاسم أو الهاتف..." value={search}
|
<input
|
||||||
onChange={e => handleSearch(e.target.value)} dir="rtl"
|
type="text"
|
||||||
style={{ width: "100%", height: 40, paddingRight: 36, paddingLeft: 12, borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, outline: "none", fontFamily: "var(--font-sans)" }}
|
placeholder="بحث بالاسم أو الهاتف..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => handleSearch(e.target.value)}
|
||||||
|
dir="rtl"
|
||||||
|
style={{
|
||||||
|
width: "100%", height: 40,
|
||||||
|
paddingRight: 36, paddingLeft: 12,
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
fontSize: 13, outline: "none",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* زر إضافة مستخدم */}
|
{/* Add user button */}
|
||||||
<button type="button" onClick={() => setFormTarget(null)}
|
<button
|
||||||
style={{ height: 40, padding: "0 1.125rem", borderRadius: "var(--radius-lg)", border: "none", background: "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 7, fontFamily: "var(--font-sans)", boxShadow: "0 1px 4px rgba(37,99,235,.35)", whiteSpace: "nowrap" }}>
|
type="button"
|
||||||
|
onClick={() => setFormTarget(null)}
|
||||||
|
style={{
|
||||||
|
height: 40, padding: "0 1.125rem",
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "none",
|
||||||
|
background: "var(--color-brand-600)",
|
||||||
|
fontSize: 13, fontWeight: 700,
|
||||||
|
color: "#FFF",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "inline-flex", alignItems: "center", gap: 7,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
boxShadow: "0 1px 4px rgba(37,99,235,.35)",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<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>
|
||||||
إضافة مستخدم
|
إضافة مستخدم
|
||||||
</button>
|
</button>
|
||||||
@@ -114,12 +162,10 @@ export default function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── رسالة الخطأ العامة (فشل التحميل) ── */}
|
{/* General load error */}
|
||||||
{error && (
|
{error && <Alert type="error" message={error} onClose={clearError} />}
|
||||||
<Alert type="error" message={error} onClose={clearError} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── جدول المستخدمين ── */}
|
{/* Users table */}
|
||||||
<UserTable
|
<UserTable
|
||||||
users={users}
|
users={users}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export function useUsers() {
|
|||||||
}
|
}
|
||||||
}, [notify]);
|
}, [notify]);
|
||||||
|
|
||||||
// deletwe user
|
// delete user
|
||||||
const deleteUser = useCallback(async (id: string): Promise<boolean> => {
|
const deleteUser = useCallback(async (id: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ function normalizePath(path: string) {
|
|||||||
|
|
||||||
// ─── API REQUESTS ─────────────────────────────
|
// ─── API REQUESTS ─────────────────────────────
|
||||||
export async function requestJson<T>(
|
export async function requestJson<T>(
|
||||||
path: string,
|
path: string, init: RequestInit = {}, token: string | null, p0: number,
|
||||||
init: RequestInit = {},
|
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const token =
|
const token =
|
||||||
typeof window !== "undefined" ? localStorage.getItem("auth_token") : null;
|
typeof window !== "undefined" ? localStorage.getItem("auth_token") : null;
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
// Base HTTP helper — no React, no UI imports.
|
import { requestJson } from "@/lib/api";
|
||||||
// All network calls in the app ultimately call `request()` from here.
|
|
||||||
|
|
||||||
/** Resolved at runtime: server-side reads INTERNAL_API_BASE_URL,
|
|
||||||
* browser always hits the Next.js proxy to avoid CORS. */
|
|
||||||
function resolveBase() {
|
function resolveBase() {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return process.env.INTERNAL_API_BASE_URL ?? "";
|
return process.env.INTERNAL_API_BASE_URL ?? "";
|
||||||
@@ -52,18 +49,22 @@ export async function request<T>(
|
|||||||
throw new ApiError(res.status, message);
|
throw new ApiError(res.status, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json() as Promise<T>;
|
//return res.json() as Promise<T>;
|
||||||
|
const text = await res.text();
|
||||||
|
return (text ? JSON.parse(text) : null) as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Convenience shorthands ────────────────────────────
|
// ── Convenience shorthands ────────────────────────────
|
||||||
export const get = <T>(path: string, token?: string | null) =>
|
export const get = <T>(path: string, token?: string | null) =>
|
||||||
request<T>(path, {}, token);
|
request<T>(path, {}, token);
|
||||||
|
|
||||||
export const post = <T>(path: string, body: unknown, token?: string | null) =>
|
export const post = <T>(path: string, body: unknown, token?: string | null) =>
|
||||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }, token);
|
request<T>(path, { method: "POST", body: JSON.stringify(body) }, token);
|
||||||
|
|
||||||
export const put = <T>(path: string, body: unknown, token?: string | null) =>
|
export const patch = <T>(path: string, body: unknown, token?: string | null) =>
|
||||||
request<T>(path, { method: "PUT", body: JSON.stringify(body) }, token);
|
request<T>(path, { method: "PATCH", body: JSON.stringify(body) }, token);
|
||||||
|
export const put = <T>(path: string, body: unknown, token?: string | null) =>
|
||||||
|
request<T>(path, { method: "PUT", body: JSON.stringify(body) }, token);
|
||||||
|
|
||||||
export const del = <T>(path: string, token?: string | null) =>
|
export const del = <T>(path: string, token?: string | null) =>
|
||||||
request<T>(path, { method: "DELETE" }, token);
|
request<T>(path, { method: "DELETE" }, token);
|
||||||
@@ -1,2 +1,131 @@
|
|||||||
|
|
||||||
export const carService = {};
|
|
||||||
|
import { get, post, patch, del } from "./api";
|
||||||
|
import type {
|
||||||
|
Car,
|
||||||
|
CarListResponse,
|
||||||
|
CarDetailResponse,
|
||||||
|
CarImageListResponse,
|
||||||
|
CreateCarPayload,
|
||||||
|
UpdateCarPayload,
|
||||||
|
} from "../types/car";
|
||||||
|
|
||||||
|
/** Build a query string for list endpoints */
|
||||||
|
function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||||
|
const entries = Object.entries(params).filter(([, v]) => v !== undefined && v !== "");
|
||||||
|
if (!entries.length) return "";
|
||||||
|
return "?" + entries.map(([k, v]) => `${k}=${encodeURIComponent(String(v))}`).join("&");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const carService = {
|
||||||
|
/**
|
||||||
|
* GET /cars
|
||||||
|
* Fetch paginated + searchable car list.
|
||||||
|
*/
|
||||||
|
getAll: (
|
||||||
|
page = 1,
|
||||||
|
search = "",
|
||||||
|
token: string | null,
|
||||||
|
) =>
|
||||||
|
get<CarListResponse>(
|
||||||
|
`cars${buildQuery({ page, limit: 10, search: search || undefined })}`,
|
||||||
|
token,
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /cars/archived
|
||||||
|
* Fetch soft-deleted cars.
|
||||||
|
*/
|
||||||
|
getArchived: (token: string | null) =>
|
||||||
|
get<CarListResponse>("cars/archived", token),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /cars/:id
|
||||||
|
* Fetch a single car with status history.
|
||||||
|
*/
|
||||||
|
getById: (id: string, token: string | null) =>
|
||||||
|
get<CarDetailResponse>(`cars/${id}`, token),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /cars
|
||||||
|
* Create a new car record.
|
||||||
|
*/
|
||||||
|
create: (payload: CreateCarPayload, token: string | null) =>
|
||||||
|
post<{ data: Car }>("cars", payload, token),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /cars/:id
|
||||||
|
* Update an existing car's details.
|
||||||
|
*/
|
||||||
|
update: (id: string, payload: UpdateCarPayload, token: string | null) =>
|
||||||
|
patch<{ data: Car }>(`cars/${id}`, payload, token),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /cars/:id
|
||||||
|
* Soft-delete a car (returns 204 No Content).
|
||||||
|
*/
|
||||||
|
delete: (id: string, token: string | null) =>
|
||||||
|
del<void>(`cars/${id}`, token),
|
||||||
|
|
||||||
|
// ── Images ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /car-images/car/:id
|
||||||
|
* Fetch all active images for a car.
|
||||||
|
* Supports optional query params: day, month, year, date, sortBy.
|
||||||
|
*/
|
||||||
|
getImages: (
|
||||||
|
carId: string,
|
||||||
|
token: string | null,
|
||||||
|
filters: { day?: string; month?: string; year?: string; date?: string; sortBy?: "asc" | "desc" } = {},
|
||||||
|
) =>
|
||||||
|
get<CarImageListResponse>(
|
||||||
|
`car-images/car/${carId}${buildQuery(filters as Record<string, string>)}`,
|
||||||
|
token,
|
||||||
|
),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /car-images/car/:id/archive
|
||||||
|
* Fetch soft-deleted images for a car.
|
||||||
|
*/
|
||||||
|
getArchivedImages: (carId: string, token: string | null) =>
|
||||||
|
get<CarImageListResponse>(`car-images/car/${carId}/archive`, token),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /car-images/car/:id (multipart/form-data)
|
||||||
|
* Upload one or more images.
|
||||||
|
* Uses raw fetch because multer expects FormData, not JSON.
|
||||||
|
*/
|
||||||
|
uploadImages: async (
|
||||||
|
carId: string,
|
||||||
|
files: File[],
|
||||||
|
stage: "BEFORE" | "AFTER" | "GENERAL" = "GENERAL",
|
||||||
|
token: string | null,
|
||||||
|
maintenanceId?: string,
|
||||||
|
): Promise<CarImageListResponse> => {
|
||||||
|
const form = new FormData();
|
||||||
|
files.forEach((f) => form.append("images", f));
|
||||||
|
form.append("stage", stage);
|
||||||
|
if (maintenanceId) form.append("maintenanceId", maintenanceId);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/proxy/car-images/car/${carId}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
body: form,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
throw new Error(json?.message ?? `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<CarImageListResponse>;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /car-images/:imageId
|
||||||
|
* Soft-delete a single image.
|
||||||
|
*/
|
||||||
|
deleteImage: (imageId: string, token: string | null) =>
|
||||||
|
del<void>(`car-images/${imageId}`, token),
|
||||||
|
};
|
||||||
149
types/car.ts
Normal file
149
types/car.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
// types/car.ts
|
||||||
|
// All TypeScript interfaces for the Car module.
|
||||||
|
|
||||||
|
export type CarStatus = "Active" | "InMaintenance" | "InTrip" | "Inactive";
|
||||||
|
export type InsuranceStatus = "Valid" | "Expired" | "NotInsured";
|
||||||
|
export type ImpoundStatus = "None" | string;
|
||||||
|
export type ImageStage = "BEFORE" | "AFTER" | "GENERAL";
|
||||||
|
|
||||||
|
// ── Core Car ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface Car {
|
||||||
|
id: string;
|
||||||
|
manufacturer: string;
|
||||||
|
model: string;
|
||||||
|
year: number;
|
||||||
|
color?: string | null;
|
||||||
|
|
||||||
|
plateNumber: string;
|
||||||
|
plateLetters: string;
|
||||||
|
plateType?: string | null;
|
||||||
|
|
||||||
|
registrationNumber?: string | null;
|
||||||
|
vinNumber?: string | null;
|
||||||
|
ownerNationalId?: string | null;
|
||||||
|
|
||||||
|
branchId?: string | null;
|
||||||
|
branch?: { id?: string; name: string } | null;
|
||||||
|
|
||||||
|
actualUserId?: string | null;
|
||||||
|
actualUserStartDate?: string | null;
|
||||||
|
ownershipStartDate?: string | null;
|
||||||
|
|
||||||
|
// Regulatory
|
||||||
|
registrationIssueDate?: string | null;
|
||||||
|
registrationExpiryDate?: string | null;
|
||||||
|
registrationPhoto?: string | null;
|
||||||
|
|
||||||
|
insuranceStatus?: InsuranceStatus | null;
|
||||||
|
insuranceExpiryDate?: string | null;
|
||||||
|
insurancePhoto?: string | null;
|
||||||
|
|
||||||
|
inspectionStatus?: string | null;
|
||||||
|
inspectionExpiryDate?: string | null;
|
||||||
|
|
||||||
|
operationCardNumber?: string | null;
|
||||||
|
operationCardExpiry?: string | null;
|
||||||
|
|
||||||
|
gpsDeviceId?: string | null;
|
||||||
|
currentStatus: CarStatus;
|
||||||
|
currentImpoundStatus?: string | null;
|
||||||
|
capacity?: number | null;
|
||||||
|
weight?: number | null;
|
||||||
|
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
|
||||||
|
// Expanded relations (detail view)
|
||||||
|
statusHistory?: CarStatusHistoryEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CarStatusHistoryEntry {
|
||||||
|
id: string;
|
||||||
|
carStatus: CarStatus;
|
||||||
|
impoundStatus?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Car Image ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CarImage {
|
||||||
|
id: string;
|
||||||
|
carId: string;
|
||||||
|
maintenanceId?: string | null;
|
||||||
|
image: string;
|
||||||
|
publicId?: string | null;
|
||||||
|
stage?: ImageStage | null;
|
||||||
|
url?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
isActive: boolean;
|
||||||
|
isDeleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payloads ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CreateCarPayload {
|
||||||
|
manufacturer: string;
|
||||||
|
model: string;
|
||||||
|
year: number;
|
||||||
|
color?: string;
|
||||||
|
plateNumber: string;
|
||||||
|
plateLetters: string;
|
||||||
|
plateType?: string;
|
||||||
|
registrationNumber?: string;
|
||||||
|
vinNumber?: string;
|
||||||
|
ownerNationalId?: string;
|
||||||
|
branchId?: string;
|
||||||
|
actualUserId?: string;
|
||||||
|
actualUserStartDate?: string;
|
||||||
|
ownershipStartDate?: string;
|
||||||
|
registrationIssueDate?: string;
|
||||||
|
registrationExpiryDate?: string;
|
||||||
|
registrationPhoto?: string;
|
||||||
|
insuranceStatus?: InsuranceStatus;
|
||||||
|
insuranceExpiryDate?: string;
|
||||||
|
insurancePhoto?: string;
|
||||||
|
inspectionStatus?: string;
|
||||||
|
inspectionExpiryDate?: string;
|
||||||
|
operationCardNumber?: string;
|
||||||
|
operationCardExpiry?: string;
|
||||||
|
gpsDeviceId?: string;
|
||||||
|
currentStatus?: CarStatus;
|
||||||
|
currentImpoundStatus?: string;
|
||||||
|
capacity?: number;
|
||||||
|
weight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateCarPayload = Partial<CreateCarPayload> & { isActive?: boolean };
|
||||||
|
|
||||||
|
// ── API Responses ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CarListResponse {
|
||||||
|
data: {
|
||||||
|
data: Car[];
|
||||||
|
pagination: { total: number; page: number; pages: number };
|
||||||
|
meta?: { total: number; pages: number };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CarDetailResponse {
|
||||||
|
data: Car;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CarImageListResponse {
|
||||||
|
data: CarImage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form state ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CarFormErrors {
|
||||||
|
manufacturer?: string;
|
||||||
|
model?: string;
|
||||||
|
year?: string;
|
||||||
|
plateNumber?: string;
|
||||||
|
plateLetters?: string;
|
||||||
|
registrationNumber?: string;
|
||||||
|
vinNumber?: string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user