create car page and update user page

This commit is contained in:
m7amedez5511
2026-06-13 17:49:48 +03:00
parent e0e38dc87a
commit 68bfce4345
12 changed files with 2320 additions and 387 deletions

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