diff --git a/Components/car/CarDeleteModal.tsx b/Components/car/CarDeleteModal.tsx new file mode 100644 index 0000000..3bcb603 --- /dev/null +++ b/Components/car/CarDeleteModal.tsx @@ -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 ( +
{ 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", + }} + > +
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 */} +
+ + + + + + +
+ +
+

+ حذف المركبة +

+

+ هل أنت متأكد من حذف{" "} + + {car.manufacturer} {car.model} + + {" "}( + + {car.plateLetters} {car.plateNumber} + + )؟ لا يمكن التراجع عن هذا الإجراء. +

+
+ +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/Components/car/CarDetailPanel.tsx b/Components/car/CarDetailPanel.tsx new file mode 100644 index 0000000..76610b7 --- /dev/null +++ b/Components/car/CarDetailPanel.tsx @@ -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 = { + 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 = { + 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 ( +
+ {label} + + {warn && value !== "—" ? "⚠ " : ""}{value} + +
+ ); +} + +// ── Component ───────────────────────────────────────────────────────────────── +export function CarDetailPanel({ carId, onClose, onEdit, onDelete }: CarDetailPanelProps) { + const [car, setCar] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 */} +
+ + {/* Panel */} + + + {/* Image gallery overlay */} + {gallery && car && ( + setGallery(false)} /> + )} + + ); +} \ No newline at end of file diff --git a/Components/car/CarFormModal.tsx b/Components/car/CarFormModal.tsx new file mode 100644 index 0000000..e33cfa0 --- /dev/null +++ b/Components/car/CarFormModal.tsx @@ -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): 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 ────────────────────────────────────────────────────────────── +// 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; +} + +// ── 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(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( + editCar?.year ?? new Date().getFullYear(), + ); + const [capacity, setCapacity] = useState( + editCar?.capacity ?? undefined, + ); + const [weight, setWeight] = useState( + editCar?.weight ?? undefined, + ); + + const [errors, setErrors] = useState({}); + const [saving, setSaving] = useState(false); + const [apiError, setApiError] = useState(""); + const firstRef = useRef(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 = { + 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 = { + 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 ( +
{ + 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", + }} + > +
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 */} +
+
+

+ {isNew ? "إضافة مركبة" : "تعديل مركبة"} +

+

+ {isNew + ? "مركبة جديدة" + : `${editCar?.manufacturer} ${editCar?.model}`} +

+
+ +
+ + {/* Form */} +
+ {apiError && ( + setApiError("")} + /> + )} + + {/* Section: Basic Info */} +

+ بيانات أساسية +

+ +
+ {/* Manufacturer — directly mapped to CreateCarPayload.manufacturer */} + + + {/* Model — directly mapped to CreateCarPayload.model */} + +
+ +
+ {/* Year — maps to CreateCarPayload.year (number) */} + + + +
+ + {/* Section: Plate */} +

+ بيانات اللوحة +

+
+ {/* plateNumber — directly mapped to CreateCarPayload.plateNumber */} + + + {/* plateLetters — directly mapped to CreateCarPayload.plateLetters */} + +
+ + {/* Section: Registration & Legal */} +

+ الترخيص والتأمين +

+
+ + + + + + +
+ + {/* Section: Operational */} +

+ بيانات تشغيلية +

+
+ + + + {/* capacity — maps to CreateCarPayload.capacity (number | undefined) */} + + {/* weight — maps to CreateCarPayload.weight (number | undefined) */} + +
+ + {/* Actions */} +
+ + +
+ +
+
+ ); +} diff --git a/Components/car/CarImageGallery.tsx b/Components/car/CarImageGallery.tsx new file mode 100644 index 0000000..6d52ef2 --- /dev/null +++ b/Components/car/CarImageGallery.tsx @@ -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 = { + 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([]); + const [loading, setLoading] = useState(true); + const [uploading, setUploading] = useState(false); + const [deleting, setDeleting] = useState(null); + const [error, setError] = useState(null); + const [lightbox, setLightbox] = useState(null); + const [stage, setStage] = useState("GENERAL"); + const [sortBy, setSortBy] = useState<"asc" | "desc">("desc"); + const fileRef = useRef(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) => { + 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 ( +
{ 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 ── */} +
+
+

+ معرض الصور +

+

+ {car.manufacturer} {car.model} — {car.plateLetters} {car.plateNumber} +

+
+
+ {/* Sort */} + + + {/* Stage selector for upload */} + + + {/* Upload button */} + + + + {/* Close */} + +
+
+ + {/* ── Error ── */} + {error && ( +
+ ⚠ {error} + +
+ )} + + {/* ── Gallery Grid ── */} +
+ {loading ? ( +
+ + جارٍ تحميل الصور… +
+ ) : images.length === 0 ? ( +
+
📸
+

لا توجد صور بعد

+

+ اضغط على "رفع صور" لإضافة أولى الصور لهذه المركبة. +

+
+ ) : ( +
+ {images.map(img => { + const stageInfo = STAGE_MAP[img.stage ?? "GENERAL"] ?? STAGE_MAP.GENERAL; + const isDeleting = deleting === img.id; + return ( +
+ {/* Image */} +
setLightbox(img)} + > + {/* eslint-disable-next-line @next/next/no-img-element */} + {`صورة { (e.target as HTMLImageElement).src = "/file.svg"; }} + /> + {/* Stage badge */} + + {stageInfo.label} + +
+ + {/* Footer */} +
+ + {new Date(img.createdAt).toLocaleDateString("ar-SA", { month: "short", day: "numeric" })} + + +
+
+ ); + })} +
+ )} +
+ + {/* ── Lightbox ── */} + {lightbox && ( +
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 */} + عرض مكبَّر e.stopPropagation()} + onError={e => { (e.target as HTMLImageElement).src = "/file.svg"; }} + /> + +
+ )} +
+ ); +} \ No newline at end of file diff --git a/Components/car/car.tsx b/Components/car/car.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/app/cars/page.tsx b/app/cars/page.tsx index b341259..30801f3 100644 --- a/app/cars/page.tsx +++ b/app/cars/page.tsx @@ -1,26 +1,16 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { getStoredToken } from "../../lib/auth"; +import { carService } from "../../services/car.service"; import { get } from "../../services/api"; 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 { - 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; -} - +// ── API response shape ──────────────────────────────────────────────────────── interface ApiResponse { data: { data: Car[]; @@ -29,365 +19,473 @@ interface ApiResponse { }; } +// ── Status badge config ─────────────────────────────────────────────────────── const STATUS_MAP: Record< 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" }, - InMaintenance: { - label: "صيانة", - color: "#854D0E", - bg: "#FFFBEB", - border: "#FDE68A", - }, - InTrip: { - label: "في رحلة", - color: "#1E40AF", - bg: "#EFF6FF", - border: "#BFDBFE", - }, - Inactive: { - label: "غير نشط", - color: "#64748B", - bg: "#F1F5F9", - border: "#E2E8F0", - }, + Active: { label: "نشط", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0", dot: "#16A34A" }, + InMaintenance: { label: "صيانة", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A", dot: "#D97706" }, + InTrip: { label: "في رحلة", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE", dot: "#3B82F6" }, + Inactive: { label: "غير نشط", color: "#64748B", bg: "#F1F5F9", border: "#E2E8F0", dot: "#94A3B8" }, }; const INS_MAP: Record = { - Valid: { label: "سارٍ", color: "#16A34A" }, - Expired: { label: "منتهي", color: "#DC2626" }, - NotInsured: { label: "غير مؤمَّن", color: "#D97706" }, + Valid: { label: "سارٍ", color: "#16A34A" }, + Expired: { label: "منتهي", color: "#DC2626" }, + NotInsured: { label: "غير مؤمَّن", color: "#D97706" }, }; function fmtDate(iso?: string) { if (!iso) return "—"; - return new Date(iso).toLocaleDateString("ar-SA", { - year: "numeric", - month: "short", - day: "numeric", - }); + return new Date(iso).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" }); } -const cardStyle: React.CSSProperties = { - borderRadius: "var(--radius-xl)", - border: "1px solid var(--color-border)", - background: "var(--color-surface)", - overflow: "hidden", - boxShadow: "var(--shadow-card)", -}; +function isExpiringSoon(iso?: string) { + if (!iso) return false; + return (new Date(iso).getTime() - Date.now()) / 86_400_000 <= 90; +} -const thStyle: React.CSSProperties = { - padding: "0.75rem 1.5rem", - fontSize: 11, - fontWeight: 700, - textTransform: "uppercase", - letterSpacing: "0.2em", - color: "var(--color-text-muted)", - background: "var(--color-surface-muted)", - borderBottom: "1px solid var(--color-border)", -}; - -export default function CarsPage() { - const [cars, setCars] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [search, setSearch] = useState(""); - const [page, setPage] = useState(1); - const [total, setTotal] = useState(0); - const [pages, setPages] = useState(1); +// ── Toast (inline — mirrors Components/User/Toast pattern) ─────────────────── +interface ToastMsg { type: "success" | "error"; message: string } +function CarToast({ notification }: { notification: ToastMsg | null }) { + const [vis, setVis] = useState(false); 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 ( +
+
+ {ok ? "✓" : "⚠"} + {notification?.message} +
+
+ ); +} + +// ── 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 ( +
{ 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 */} +
+ +
+ {/* Manufacturer + model */} +
+
+

+ {car.manufacturer} {car.model} +

+

+ {car.year}{car.color ? ` · ${car.color}` : ""} +

+
+ {/* Status badge */} + + + {status.label} + +
+ + {/* Plate number — prominent */} +
+ رقم اللوحة + + {car.plateLetters} {car.plateNumber} + +
+ + {/* Key attributes grid */} +
+ {/* Branch */} +
+ الفرع + + {car.branch?.name ?? "—"} + +
+ {/* Insurance */} +
+ التأمين + + {ins?.label ?? "—"} + +
+ {/* Registration expiry */} +
+ انتهاء الاستمارة + + {regWarn && "⚠ "}{fmtDate(car.registrationExpiryDate as string | undefined)} + +
+ {/* Capacity */} +
+ الطاقة + + {car.capacity != null ? car.capacity : "—"} + +
+
+ + {/* Footer CTA hint */} +

+ اضغط لعرض التفاصيل ← +

+
+
+ ); +} + +// ── Page ────────────────────────────────────────────────────────────────────── +export default function CarsPage() { + // List state + const [cars, setCars] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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(null); // CarDetailPanel + const [formTarget, setFormTarget] = useState(false); // false=closed + const [deleteTarget, setDeleteTarget] = useState(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(); setLoading(true); setError(null); - const query = `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`; + const query = `?page=${page}&limit=12${search ? `&search=${encodeURIComponent(search)}` : ""}`; get(`cars${query}`, token) .then((res) => { - const payload = res.data ?? res; - setCars(payload.data ?? []); - setTotal(payload.meta?.total ?? 0); - setPages(payload.meta?.pages ?? 1); + const payload = (res as unknown as { data: ApiResponse["data"] }).data ?? res; + setCars((payload as ApiResponse["data"]).data ?? []); + setTotal((payload as ApiResponse["data"]).meta?.total ?? 0); + setPages((payload as ApiResponse["data"]).meta?.pages ?? 1); }) .catch((err: Error) => setError(err.message)) .finally(() => setLoading(false)); }, [page, search]); + useEffect(() => { loadCars(); }, [loadCars]); + + // ── Create / Update ──────────────────────────────────────────────────────── + const handleFormSubmit = async ( + payload: CreateCarPayload | UpdateCarPayload, + isNew: boolean, + ): Promise => { + 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 ( -
- {/* Header */} -
+ {/* Toast notification */} + + + {/* Detail panel (slide-in) */} + {detailId && ( + setDetailId(null)} + onEdit={(car) => { setDetailId(null); setFormTarget(car); }} + onDelete={(car) => { setDetailId(null); setDeleteTarget(car); }} + /> + )} + + {/* Create / Edit modal */} + {formTarget !== false && ( + setFormTarget(false)} + onSubmit={handleFormSubmit} + /> + )} + + {/* Delete confirmation */} + {deleteTarget && ( + setDeleteTarget(null)} + onConfirm={handleDeleteConfirm} + /> + )} + +
+ + {/* ── Header ── */} +
-

- إدارة الأسطول -

-
-
-

- السيارات -

-

- إجمالي{" "} - - {total} - {" "} - مركبة في الأسطول -

+ }}> +

+ إدارة الأسطول +

+
+
+

+ المركبات +

+

+ إجمالي {total} مركبة في الأسطول +

+
+ +
+ {/* Search */} +
+ + + + { 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)", + }} + /> +
+ + {/* Add button */} + +
-
- - - - - { - 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)", - }} - /> -
-
-
+
- {error && ( - setError(null)} /> - )} - - {/* Table */} -
-
- السيارة - اللوحة - الفرع - الحالة - التأمين - انتهاء الاستمارة -
+ {/* Error alert */} + {error && setError(null)} />} + {/* ── Loading ── */} {loading ? ( -
- - جارٍ التحميل… +
+ + جارٍ تحميل المركبات…
) : cars.length === 0 ? ( -

- لا توجد نتائج {search && `لـ "${search}"`} -

+ /* Empty state */ +
+
🚗
+

+ {search ? `لا توجد مركبات تطابق "${search}"` : "لا توجد مركبات بعد"} +

+

+ اضغط على "إضافة مركبة" لإضافة أول مركبة في الأسطول. +

+ {!search && ( + + )} +
) : ( -
    - {cars.map((c, i) => { - const status = STATUS_MAP[c.currentStatus]; - const ins = c.insuranceStatus ? INS_MAP[c.insuranceStatus] : null; - return ( -
  • -
    -

    - {c.manufacturer} {c.model} -

    -

    - {c.year} - {c.color ? ` · ${c.color}` : ""} -

    -
    - - {c.plateLetters} {c.plateNumber} - - - {c.branch?.name ?? "—"} - - - {status.label} - - - {ins?.label ?? "—"} - - - {fmtDate(c.registrationExpiryDate)} - -
  • - ); - })} -
+ /* ── Card grid ── */ +
+ {cars.map((car) => ( + setDetailId(car.id)} + /> + ))} +
)} + {/* ── Pagination ── */} {pages > 1 && ( -
+
- صفحة{" "} - - {page} - {" "} - من{" "} - - {pages} - + صفحة {page} من{" "} + {pages} + {" · "} + {total} مركبة
{[ - { - label: "السابق", - action: () => setPage((p) => Math.max(1, p - 1)), - disabled: page === 1, - }, - { - label: "التالي", - action: () => setPage((p) => Math.min(pages, p + 1)), - disabled: page === pages, - }, + { label: "← السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 }, + { label: "التالي →", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages }, ].map((btn) => (
)} -
-
+ + ); -} +} \ No newline at end of file diff --git a/app/users/page.tsx b/app/users/page.tsx index 283a73c..53290d0 100644 --- a/app/users/page.tsx +++ b/app/users/page.tsx @@ -1,23 +1,23 @@ -// صفحة إدارة المستخدمين — تستورد فقط من الطبقات الأخرى "use client"; import { useState } from "react"; -import { Alert } from "../../Components/UI"; -import { UserTable } from "../../Components/User/UserTable"; -import { UserFormModal } from "../../Components/User/UserFormModal"; +import { Alert } from "../../Components/UI"; +import { UserTable } from "../../Components/User/UserTable"; +import { UserFormModal } from "../../Components/User/UserFormModal"; import { DeleteConfirmModal } from "../../Components/User/DeleteConfirmModal"; -import { Toast } from "../../Components/User/Toast"; -import { useUsers } from "../../hooks/useUser"; +import { Toast } from "../../Components/User/Toast"; +import { useUsers } from "../../hooks/useUser"; import type { User, UserFormData } from "../../types/user"; export default function UsersPage() { - // ── الحالة المحلية للمودالات فقط ───────────────────────────────────────── - // false = مغلق، null = وضع الإضافة، User = وضع التعديل - const [formTarget, setFormTarget] = useState(false); - const [deleteTarget, setDeleteTarget] = useState(null); - const [deleting, setDeleting] = useState(false); + // ── Modal state ───────────────────────────────────────────────────────────── + // false = closed | null = create mode | User = edit mode + const [formTarget, setFormTarget] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + // Local submitting flag shown in DeleteConfirmModal spinner + const [deleting, setDeleting] = useState(false); - // ── كل منطق البيانات والحالة من الهوك ──────────────────────────────────── + // ── Data hook ─────────────────────────────────────────────────────────────── const { users, loading, total, pages, error, roles, branches, @@ -27,27 +27,41 @@ export default function UsersPage() { notification, } = useUsers(); - // ── معالج تقديم النموذج (إضافة أو تعديل) ──────────────────────────────── + // ── Create / Update handler ───────────────────────────────────────────────── const handleFormSubmit = async (data: UserFormData, isNew: boolean): Promise => { if (isNew) return createUser(data); + // formTarget is User when editing 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 () => { - if (!deleteTarget) return; + if (!deleteTarget || deleting) return; setDeleting(true); const ok = await deleteUser(deleteTarget.id); + console.log("Delete result:", ok); 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 ( <> - {/* ── Toast لعرض رسائل النجاح والخطأ ── */} + {/* Global success / error toast — fires on create, update, AND delete */} - {/* ── مودال الإضافة / التعديل ── */} + {/* Create / Edit modal */} {formTarget !== false && ( )} - {/* ── مودال تأكيد الحذف ── */} + {/* Delete confirmation dialog */} {deleteTarget && ( setDeleteTarget(null)} + onCancel={() => { + // Only allow cancel when not mid-flight + if (!deleting) setDeleteTarget(null); + }} onConfirm={handleDeleteConfirm} /> )}
- {/* ── رأس الصفحة ── */} + {/* ── Page header ── */}

إدارة الفريق @@ -88,25 +108,53 @@ export default function UsersPage() {

- {/* أدوات البحث وإضافة مستخدم */}
- {/* حقل البحث */} + {/* Search input */}
- 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)" }} + 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)", + }} />
- {/* زر إضافة مستخدم */} - @@ -114,12 +162,10 @@ export default function UsersPage() {
- {/* ── رسالة الخطأ العامة (فشل التحميل) ── */} - {error && ( - - )} + {/* General load error */} + {error && } - {/* ── جدول المستخدمين ── */} + {/* Users table */} => { try { const token = getStoredToken(); diff --git a/lib/api.ts b/lib/api.ts index 2789ec4..e86563a 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -9,8 +9,7 @@ function normalizePath(path: string) { // ─── API REQUESTS ───────────────────────────── export async function requestJson( - path: string, - init: RequestInit = {}, +path: string, init: RequestInit = {}, token: string | null, p0: number, ): Promise { const token = typeof window !== "undefined" ? localStorage.getItem("auth_token") : null; diff --git a/services/api.ts b/services/api.ts index a1fbbd6..a87a55e 100644 --- a/services/api.ts +++ b/services/api.ts @@ -1,8 +1,5 @@ -// Base HTTP helper — no React, no UI imports. -// All network calls in the app ultimately call `request()` from here. +import { requestJson } from "@/lib/api"; -/** Resolved at runtime: server-side reads INTERNAL_API_BASE_URL, - * browser always hits the Next.js proxy to avoid CORS. */ function resolveBase() { if (typeof window === "undefined") { return process.env.INTERNAL_API_BASE_URL ?? ""; @@ -52,18 +49,22 @@ export async function request( throw new ApiError(res.status, message); } - return res.json() as Promise; + //return res.json() as Promise; + const text = await res.text(); + return (text ? JSON.parse(text) : null) as Promise; } // ── Convenience shorthands ──────────────────────────── -export const get = (path: string, token?: string | null) => +export const get = (path: string, token?: string | null) => request(path, {}, token); export const post = (path: string, body: unknown, token?: string | null) => request(path, { method: "POST", body: JSON.stringify(body) }, token); -export const put = (path: string, body: unknown, token?: string | null) => - request(path, { method: "PUT", body: JSON.stringify(body) }, token); +export const patch = (path: string, body: unknown, token?: string | null) => + request(path, { method: "PATCH", body: JSON.stringify(body) }, token); +export const put = (path: string, body: unknown, token?: string | null) => + request(path, { method: "PUT", body: JSON.stringify(body) }, token); -export const del = (path: string, token?: string | null) => - request(path, { method: "DELETE" }, token); \ No newline at end of file +export const del = (path: string, token?: string | null) => + request(path, { method: "DELETE" }, token); diff --git a/services/car.service.ts b/services/car.service.ts index f299596..1d46c47 100644 --- a/services/car.service.ts +++ b/services/car.service.ts @@ -1,2 +1,131 @@ -export const carService = {}; \ No newline at end of file + +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 { + 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( + `cars${buildQuery({ page, limit: 10, search: search || undefined })}`, + token, + ), + + /** + * GET /cars/archived + * Fetch soft-deleted cars. + */ + getArchived: (token: string | null) => + get("cars/archived", token), + + /** + * GET /cars/:id + * Fetch a single car with status history. + */ + getById: (id: string, token: string | null) => + get(`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(`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( + `car-images/car/${carId}${buildQuery(filters as Record)}`, + token, + ), + + /** + * GET /car-images/car/:id/archive + * Fetch soft-deleted images for a car. + */ + getArchivedImages: (carId: string, token: string | null) => + get(`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 => { + 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; + }, + + /** + * DELETE /car-images/:imageId + * Soft-delete a single image. + */ + deleteImage: (imageId: string, token: string | null) => + del(`car-images/${imageId}`, token), +}; \ No newline at end of file diff --git a/types/car.ts b/types/car.ts new file mode 100644 index 0000000..71eaab2 --- /dev/null +++ b/types/car.ts @@ -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 & { 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; +} \ No newline at end of file