Files
LogisicsApp_Client/src/Components/Driver/DriverFormModal.tsx

447 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Button, FileInput, Input, Modal, Select } from "../UI";
import { get } from "@/src/services/api";
import { getStoredToken } from "@/src/lib/auth";
import { createDriverSchema, updateDriverSchema } from "@/src/validations/driver.validator";
import type {
Driver,
CreateDriverPayload,
UpdateDriverPayload,
NationalIdType,
DriverCardType,
DriverStatus,
} from "@/src/types/driver";
import type { Branch } from "@/src/types/branch";
// ── Shared bits ──────────────────────────────────────────────────────────────
const sectionHeadingStyle: React.CSSProperties = {
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "var(--color-text-hint)",
fontWeight: 700,
margin: "0.5rem 0 0",
};
const FORM_ID = "driver-form";
// ── Date helper ──────────────────────────────────────────────────────────────
function toIsoDateTime(val: string): string {
if (!val) return val;
if (val.includes("T")) return val;
return `${val}T00:00:00.000Z`;
}
// ── Form values shape ────────────────────────────────────────────────────────
interface DriverFormValues {
name: string;
phone: string;
email: string;
address: string;
nationality: string;
nationalIdType: NationalIdType | "";
nationalId: string;
nationalIdExpiry: string;
gosiNumber: string;
licenseNumber: string;
licenseType: string;
licenseExpiry: string;
driverCardNumber: string;
driverCardType: DriverCardType | "";
driverCardExpiry: string;
driverType: string;
branchId: string;
status: DriverStatus;
photo: File | null;
nationalPhoto: File | null;
driverCardPhoto: File | null;
}
// ── Props ────────────────────────────────────────────────────────────────────
interface DriverFormModalProps {
editDriver: Driver | null;
branches: Branch[];
onClose: () => void;
onSubmit: (
payload: CreateDriverPayload | UpdateDriverPayload,
isNew: boolean,
) => Promise<boolean>;
}
// ── Component ────────────────────────────────────────────────────────────────
export function DriverFormModal({
editDriver,
branches: branchesProp,
onClose,
onSubmit,
}: DriverFormModalProps) {
const isNew = editDriver === null;
// ── Local branches (auto-fetched if prop is empty) ─────────────────────────
const [branches, setBranches] = useState<Branch[]>(branchesProp);
useEffect(() => {
if (branchesProp.length > 0) {
queueMicrotask(() => setBranches(branchesProp));
return;
}
const token = getStoredToken();
get<{ data: { data: Branch[] } }>("branches?limit=100", token)
.then((res) => {
const list = (res as unknown as { data: { data: Branch[] } }).data?.data ?? [];
setBranches(list);
})
.catch(() => {
/* silently ignore */
});
}, [branchesProp]);
// ── react-hook-form ────────────────────────────────────────────────────────
const {
register,
handleSubmit,
control,
setError,
setFocus,
formState: { errors, isSubmitting },
} = useForm<DriverFormValues>({
// Cast the schema itself — the create/update schemas have structurally
// different required fields, so yup can't unify them into one type.
resolver: yupResolver((isNew ? createDriverSchema : updateDriverSchema) as any),
defaultValues: {
name: editDriver?.name ?? "",
phone: editDriver?.phone ?? "",
email: editDriver?.email ?? "",
address: editDriver?.address ?? "",
nationality: editDriver?.nationality ?? "",
nationalIdType: editDriver?.nationalIdType ?? "",
nationalId: (editDriver as Driver & { nationalId?: string })?.nationalId ?? "",
nationalIdExpiry:
(editDriver as Driver & { nationalIdExpiry?: string })?.nationalIdExpiry?.slice(0, 10) ?? "",
gosiNumber: editDriver?.gosiNumber ?? "",
licenseNumber: editDriver?.licenseNumber ?? "",
licenseType: editDriver?.licenseType ?? "",
licenseExpiry: editDriver?.licenseExpiry?.slice(0, 10) ?? "",
driverCardNumber: editDriver?.driverCardNumber ?? "",
driverCardType: editDriver?.driverCardType ?? "",
driverCardExpiry: editDriver?.driverCardExpiry?.slice(0, 10) ?? "",
driverType: editDriver?.driverType ?? "",
branchId: (editDriver as Driver & { branchId?: string })?.branchId ?? "",
status: editDriver?.status ?? "Active",
photo: null,
nationalPhoto: null,
driverCardPhoto: null,
},
});
const [apiError, setApiError] = useState("");
// register("name") already attaches its own ref — focusing via setFocus
// avoids the ref collision that a separate `ref={firstRef}` would cause.
useEffect(() => {
setFocus("name");
}, [setFocus]);
// ── Submit ────────────────────────────────────────────────────────────────
const submitHandler = useCallback(
async (data: DriverFormValues) => {
const payload: Record<string, unknown> = { name: data.name, phone: data.phone };
if (data.email) payload.email = data.email;
if (data.address) payload.address = data.address;
if (data.nationality) payload.nationality = data.nationality;
if (data.nationalIdType) payload.nationalIdType = data.nationalIdType;
if (data.nationalId) payload.nationalId = data.nationalId;
if (data.nationalIdExpiry) payload.nationalIdExpiry = toIsoDateTime(data.nationalIdExpiry);
if (data.gosiNumber) payload.gosiNumber = data.gosiNumber;
if (data.licenseNumber) payload.licenseNumber = data.licenseNumber;
if (data.licenseType) payload.licenseType = data.licenseType;
if (data.licenseExpiry) payload.licenseExpiry = toIsoDateTime(data.licenseExpiry);
if (data.driverCardNumber) payload.driverCardNumber = data.driverCardNumber;
if (data.driverCardType) payload.driverCardType = data.driverCardType;
if (data.driverCardExpiry) payload.driverCardExpiry = toIsoDateTime(data.driverCardExpiry);
if (data.driverType) payload.driverType = data.driverType;
if (data.branchId) payload.branchId = data.branchId;
if (!isNew) payload.status = data.status;
// File fields are excluded from the JSON diff unless actually chosen —
// matches the pre-refactor behavior exactly.
if (data.photo) payload.photo = data.photo;
if (data.nationalPhoto) payload.nationalPhoto = data.nationalPhoto;
if (data.driverCardPhoto) payload.driverCardPhoto = data.driverCardPhoto;
setApiError("");
try {
const ok = await onSubmit(payload as unknown as CreateDriverPayload, isNew);
if (ok) {
onClose();
} else {
setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." });
setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
}
} catch (err) {
const message = err instanceof Error ? err.message : "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.";
setError("name", { message });
setApiError(message);
}
},
[isNew, onSubmit, onClose, setError],
);
// ── Render ────────────────────────────────────────────────────────────────
return (
<Modal
open
onClose={onClose}
size="lg"
subtitle={isNew ? "إضافة سائق" : "تعديل سائق"}
title={isNew ? "سائق جديد" : editDriver?.name ?? ""}
footer={
<>
<Button variant="secondary" type="button" onClick={onClose} disabled={isSubmitting}>
إلغاء
</Button>
<Button type="submit" form={FORM_ID} loading={isSubmitting}>
{isNew ? "إضافة السائق" : "حفظ التغييرات"}
</Button>
</>
}
>
<form
id={FORM_ID}
onSubmit={handleSubmit(submitHandler)}
noValidate
dir="rtl"
className="flex flex-col gap-4"
>
{errors.name?.type === "manual" && (
<Alert type="error" message={errors.name.message ?? ""} onClose={() => setApiError("")} />
)}
{/* ── Section: Personal Info ── */}
<p style={sectionHeadingStyle}>البيانات الشخصية</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input
label="الاسم الكامل *"
placeholder="محمد عبدالله"
dir="rtl"
autoComplete="off"
error={errors.name?.type !== "manual" ? errors.name?.message : undefined}
{...register("name")}
/>
<Input
label="رقم الجوال *"
placeholder="05XXXXXXXX"
dir="ltr"
error={errors.phone?.message}
{...register("phone")}
/>
<Input
label="البريد الإلكتروني (اختياري)"
type="email"
placeholder="example@mail.com"
dir="ltr"
error={errors.email?.message}
{...register("email")}
/>
<Input
label="الجنسية (اختياري)"
placeholder="سعودي"
dir="rtl"
error={errors.nationality?.message}
{...register("nationality")}
/>
<Input
className="sm:col-span-2"
label="العنوان (اختياري)"
placeholder="الرياض، حي..."
dir="rtl"
error={errors.address?.message}
{...register("address")}
/>
</div>
{/* ── Section: ID & GOSI ── */}
<p style={sectionHeadingStyle}>الهوية والتأمينات</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Select
label="نوع الهوية (اختياري)"
dir="rtl"
error={errors.nationalIdType?.message}
{...register("nationalIdType")}
>
<option value="">اختر النوع</option>
<option value="NationalID">هوية وطنية</option>
<option value="Iqama">إقامة</option>
<option value="Passport">جواز سفر</option>
</Select>
<Input
label="رقم الهوية (اختياري)"
placeholder="1XXXXXXXXX"
dir="ltr"
error={errors.nationalId?.message}
{...register("nationalId")}
/>
<Input
label="انتهاء الهوية (اختياري)"
type="date"
error={errors.nationalIdExpiry?.message}
{...register("nationalIdExpiry")}
/>
<Input
label="رقم GOSI (اختياري)"
placeholder="GOSI-XXXX"
dir="ltr"
error={errors.gosiNumber?.message}
{...register("gosiNumber")}
/>
</div>
{/* ── Section: License ── */}
<p style={sectionHeadingStyle}>رخصة القيادة</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Input
label="رقم الرخصة (اختياري)"
placeholder="LIC-XXXX"
dir="ltr"
error={errors.licenseNumber?.message}
{...register("licenseNumber")}
/>
<Input
label="نوع الرخصة (اختياري)"
placeholder="خاص / عام"
dir="rtl"
error={errors.licenseType?.message}
{...register("licenseType")}
/>
<Input
label="انتهاء الرخصة (اختياري)"
type="date"
error={errors.licenseExpiry?.message}
{...register("licenseExpiry")}
/>
</div>
{/* ── Section: Driver Card ── */}
<p style={sectionHeadingStyle}>بطاقة السائق</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Input
label="رقم البطاقة (اختياري)"
placeholder="CARD-XXXX"
dir="ltr"
error={errors.driverCardNumber?.message}
{...register("driverCardNumber")}
/>
<Select
label="نوع البطاقة (اختياري)"
dir="rtl"
error={errors.driverCardType?.message}
{...register("driverCardType")}
>
<option value="">اختر النوع</option>
<option value="Temporary">مؤقتة</option>
<option value="Seasonal">موسمية</option>
<option value="Annual">سنوية</option>
<option value="Restricted">مقيدة</option>
</Select>
<Input
label="انتهاء البطاقة (اختياري)"
type="date"
error={errors.driverCardExpiry?.message}
{...register("driverCardExpiry")}
/>
</div>
{/* ── Section: Operational ── */}
<p style={sectionHeadingStyle}>بيانات تشغيلية</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<Select
label="الفرع (اختياري)"
dir="rtl"
error={errors.branchId?.message}
{...register("branchId")}
>
<option value="">اختر الفرع</option>
{branches.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option>
))}
</Select>
<Input
label="نوع السائق (اختياري)"
placeholder="رئيسي / احتياطي"
dir="rtl"
error={errors.driverType?.message}
{...register("driverType")}
/>
{/* Status — edit only */}
{!isNew && (
<Select label="الحالة" dir="rtl" {...register("status")}>
<option value="Active">نشط</option>
<option value="InTrip">في رحلة</option>
<option value="Inactive">غير نشط</option>
<option value="Suspended">موقوف</option>
</Select>
)}
</div>
{/* ── Section: Photos ── */}
<p style={sectionHeadingStyle}>الصور والمستندات</p>
<p style={{ fontSize: 11, color: "var(--color-text-hint)", margin: "0.25rem 0 0", fontWeight: 400 }}>
جميع حقول الصور اختيارية
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{/* File inputs cannot use register() directly — bind via Controller
to the existing FileInput component's current/onChange props. */}
<Controller
name="photo"
control={control}
render={({ field }) => (
<FileInput label="صورة السائق" current={field.value} onChange={field.onChange} />
)}
/>
<Controller
name="nationalPhoto"
control={control}
render={({ field }) => (
<FileInput label="صورة الهوية" current={field.value} onChange={field.onChange} />
)}
/>
<Controller
name="driverCardPhoto"
control={control}
render={({ field }) => (
<FileInput label="صورة البطاقة" current={field.value} onChange={field.onChange} />
)}
/>
</div>
</form>
</Modal>
);
}