The trip files have been reorganized. Errors related to vehicle and driver editing have been resolved, archive display issues fixed, and model data display standardized across the project; order and trip-related issues have also been addressed.
This commit is contained in:
@@ -1,9 +1,21 @@
|
||||
"use client";
|
||||
|
||||
// src/Components/Trip/TripFormModal.tsx
|
||||
// CHANGE: renamed from Tripformmodal.tsx (proper PascalCase, matches
|
||||
// UserFormModal.tsx / DriverFormModal.tsx). Split out of the old single-file
|
||||
// Trip module into TripFormModal.tsx / TripTable.tsx / TripDetailModal.tsx.
|
||||
// FIX 1: notes/endReason schema — empty string now transforms to undefined
|
||||
// before .min() runs, so leaving them blank on create no longer fails
|
||||
// validation despite .optional() (see trip.validator.ts).
|
||||
// FIX 2: edit-mode defaultValues now fall back to the nested driver/car/branch
|
||||
// ids, and setValue() re-applies the saved id once each dropdown's options
|
||||
// actually load — previously the <select> had no matching <option> yet when
|
||||
// defaultValues were first applied, so the old selection silently reset to "".
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { Alert, Button, Input, Select, Textarea, Spinner } from "../UI";
|
||||
import { Alert, Button, Input, Modal, Select, Textarea } from "../UI";
|
||||
import { getStoredToken } from "@/src/lib/auth";
|
||||
import {
|
||||
createTripSchema,
|
||||
@@ -21,6 +33,8 @@ import type { DriverOption } from "@/src/types/driver";
|
||||
import type { CarOption } from "@/src/types/car";
|
||||
import type { BranchOption } from "@/src/types/branch";
|
||||
|
||||
const FORM_ID = "trip-form";
|
||||
|
||||
// ── Shared styles ────────────────────────────────────────────────────────────
|
||||
// Only for section headings — everything field-level now comes from the
|
||||
// Input / Select / Textarea components themselves.
|
||||
@@ -82,29 +96,34 @@ export function TripFormModal({
|
||||
const [cars, setCars] = useState<CarOption[]>([]);
|
||||
const [branches, setBranches] = useState<BranchOption[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
driverService.getActiveOptions(token).then(setDrivers).catch(() => {});
|
||||
carService.getActiveOptions(token).then(setCars).catch(() => {});
|
||||
branchService.getOptions(token).then(setBranches).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ── react-hook-form ────────────────────────────────────────────────────────
|
||||
// Moved above the dropdown-loading effect below: that effect calls
|
||||
// setValue(), so useForm() (which defines it) must run first — otherwise
|
||||
// setValue is referenced before its own initialization (TDZ ReferenceError).
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
setFocus,
|
||||
setValue,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<TripFormValues>({
|
||||
// Cast the schema itself — create/update schemas differ structurally in
|
||||
// which fields are required, so yup can't unify them into one type.
|
||||
resolver: yupResolver<TripFormValues>((isNew ? createTripSchema : updateTripSchema) as any),
|
||||
// Cast both the schema argument and the resolver's result:
|
||||
// - argument: createTripSchema/updateTripSchema have structurally
|
||||
// different shapes (create requires title/driverId/etc., update makes
|
||||
// everything optional), so the ternary's type is a union yupResolver's
|
||||
// single-schema signature won't accept.
|
||||
// - result: yup's inferred type for .optional() fields
|
||||
// (`collectedCount?: number`) is structurally incompatible with
|
||||
// TripFormValues's `collectedCount: number | undefined` — letting
|
||||
// useForm<TripFormValues> alone establish the field-values type avoids
|
||||
// that mismatch.
|
||||
resolver: yupResolver((isNew ? createTripSchema : updateTripSchema) as any) as any,
|
||||
defaultValues: {
|
||||
title: editTrip?.title ?? "",
|
||||
driverId: editTrip?.driverId ?? "",
|
||||
carId: editTrip?.carId ?? "",
|
||||
branchId: editTrip?.branchId ?? "",
|
||||
driverId: editTrip?.driverId ?? editTrip?.driver?.id ?? "",
|
||||
carId: editTrip?.carId ?? editTrip?.car?.id ?? "",
|
||||
branchId: editTrip?.branchId ?? editTrip?.branch?.id ?? "",
|
||||
status: editTrip?.status ?? "Scheduled",
|
||||
startTime: editTrip?.startTime?.slice(0, 16) ?? "",
|
||||
endTime: editTrip?.endTime?.slice(0, 16) ?? "",
|
||||
@@ -119,6 +138,31 @@ export function TripFormModal({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
|
||||
driverService.getActiveOptions(token).then((list) => {
|
||||
setDrivers(list);
|
||||
// defaultValues were applied before this list existed, so the <select>
|
||||
// had no matching <option> yet and silently fell back to "" — reapply
|
||||
// the saved id now that the option actually exists in the DOM.
|
||||
const savedDriverId = editTrip?.driverId ?? editTrip?.driver?.id;
|
||||
if (savedDriverId) setValue("driverId", savedDriverId);
|
||||
}).catch(() => {});
|
||||
|
||||
carService.getActiveOptions(token).then((list) => {
|
||||
setCars(list);
|
||||
const savedCarId = editTrip?.carId ?? editTrip?.car?.id;
|
||||
if (savedCarId) setValue("carId", savedCarId);
|
||||
}).catch(() => {});
|
||||
|
||||
branchService.getOptions(token).then((list) => {
|
||||
setBranches(list);
|
||||
const savedBranchId = editTrip?.branchId ?? editTrip?.branch?.id;
|
||||
if (savedBranchId) setValue("branchId", savedBranchId);
|
||||
}).catch(() => {});
|
||||
}, [editTrip, setValue]);
|
||||
|
||||
const [apiError, setApiError] = useState("");
|
||||
|
||||
// register("title") already attaches its own ref — Input forwards it
|
||||
@@ -127,13 +171,6 @@ export function TripFormModal({
|
||||
useEffect(() => {
|
||||
setFocus("title");
|
||||
}, [setFocus]);
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", h);
|
||||
return () => window.removeEventListener("keydown", h);
|
||||
}, [onClose]);
|
||||
|
||||
const numberField = (
|
||||
field: "collectedCount" | "deliveredCount" | "returnedCount" | "totalCashCollected",
|
||||
@@ -189,273 +226,205 @@ export function TripFormModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="trip-form-title"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 60,
|
||||
background: "rgba(15,23,42,0.55)",
|
||||
backdropFilter: "blur(4px)",
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
padding: "2rem 1rem",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 700,
|
||||
background: "var(--color-surface)",
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
boxShadow: "0 24px 56px rgba(0,0,0,.2)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "1.25rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
id="trip-form-title"
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{isNew ? "إضافة رحلة جديدة" : "تعديل بيانات الرحلة"}
|
||||
</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
aria-label="إغلاق"
|
||||
className="!px-1"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
title={isNew ? "إضافة رحلة جديدة" : "تعديل بيانات الرحلة"}
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||
إلغاء
|
||||
</Button>
|
||||
<Button type="submit" form={FORM_ID} variant="primary" loading={isSubmitting}>
|
||||
{isNew ? "إضافة الرحلة" : "حفظ التغييرات"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form
|
||||
id={FORM_ID}
|
||||
onSubmit={handleSubmit(submitHandler)}
|
||||
noValidate
|
||||
dir="rtl"
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
{errors.title?.type === "manual" && (
|
||||
<Alert type="error" message={errors.title.message ?? ""} onClose={() => setApiError("")} />
|
||||
)}
|
||||
|
||||
{/* ── Section: trip management ── */}
|
||||
<p style={sectionHeadingStyle}>أساسيات الرحلة</p>
|
||||
|
||||
{/* Title */}
|
||||
<Input
|
||||
label="عنوان الرحلة *"
|
||||
error={titleError}
|
||||
{...register("title")}
|
||||
placeholder="مثال: توزيع الرياض الشمالي"
|
||||
dir="rtl"
|
||||
/>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
{/* Driver */}
|
||||
<Select
|
||||
label="السائق *"
|
||||
error={errors.driverId?.message}
|
||||
{...register("driverId")}
|
||||
dir="rtl"
|
||||
>
|
||||
<option value="">اختر السائق</option>
|
||||
{drivers.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name} — {d.phone}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Car */}
|
||||
<Select
|
||||
label="السيارة *"
|
||||
error={errors.carId?.message}
|
||||
{...register("carId")}
|
||||
dir="rtl"
|
||||
>
|
||||
<option value="">اختر السيارة</option>
|
||||
{cars.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.manufacturer} {c.model} — {c.plateNumber}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
onSubmit={handleSubmit(submitHandler)}
|
||||
noValidate
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
}}
|
||||
>
|
||||
{errors.title?.type === "manual" && (
|
||||
<Alert type="error" message={errors.title.message ?? ""} onClose={() => setApiError("")} />
|
||||
)}
|
||||
|
||||
{/* ── Section: trip management ── */}
|
||||
<p style={sectionHeadingStyle}>أساسيات الرحلة</p>
|
||||
|
||||
{/* Title */}
|
||||
<Input
|
||||
label="عنوان الرحلة *"
|
||||
error={titleError}
|
||||
{...register("title")}
|
||||
placeholder="مثال: توزيع الرياض الشمالي"
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
{/* Branch */}
|
||||
<Select
|
||||
label="الفرع *"
|
||||
error={errors.branchId?.message}
|
||||
{...register("branchId")}
|
||||
dir="rtl"
|
||||
>
|
||||
<option value="">اختر الفرع</option>
|
||||
{branches.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Status */}
|
||||
<Select label="الحالة" {...register("status")} dir="rtl">
|
||||
<option value="Scheduled">مجدولة</option>
|
||||
<option value="InProgress">جارية</option>
|
||||
<option value="Completed">مكتملة</option>
|
||||
<option value="Cancelled">ملغاة</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* ── Section: التوقيت ── */}
|
||||
<p style={sectionHeadingStyle}>التوقيت</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<Input
|
||||
label="وقت البدء"
|
||||
hint="اختياري"
|
||||
type="datetime-local"
|
||||
error={errors.startTime?.message}
|
||||
{...dateTimeField("startTime")}
|
||||
/>
|
||||
<Input
|
||||
label="وقت الانتهاء"
|
||||
hint="اختياري"
|
||||
type="datetime-local"
|
||||
error={errors.endTime?.message}
|
||||
{...dateTimeField("endTime")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
{/* Driver */}
|
||||
<Select
|
||||
label="السائق *"
|
||||
error={errors.driverId?.message}
|
||||
{...register("driverId")}
|
||||
{/* ── Section: الأعداد والمبالغ ── */}
|
||||
<p style={sectionHeadingStyle}>الأعداد والمبالغ</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||
<Input
|
||||
label="المجمّع"
|
||||
hint="اختياري"
|
||||
type="number"
|
||||
min="0"
|
||||
error={errors.collectedCount?.message}
|
||||
{...numberField("collectedCount")}
|
||||
placeholder="0"
|
||||
dir="ltr"
|
||||
/>
|
||||
<Input
|
||||
label="المُسلَّم"
|
||||
hint="اختياري"
|
||||
type="number"
|
||||
min="0"
|
||||
error={errors.deliveredCount?.message}
|
||||
{...numberField("deliveredCount")}
|
||||
placeholder="0"
|
||||
dir="ltr"
|
||||
/>
|
||||
<Input
|
||||
label="المُرتجع"
|
||||
hint="اختياري"
|
||||
type="number"
|
||||
min="0"
|
||||
error={errors.returnedCount?.message}
|
||||
{...numberField("returnedCount")}
|
||||
placeholder="0"
|
||||
dir="ltr"
|
||||
/>
|
||||
<Input
|
||||
label="النقد المحصّل"
|
||||
hint="اختياري"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
error={errors.totalCashCollected?.message}
|
||||
{...numberField("totalCashCollected")}
|
||||
placeholder="0.00"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Section: ملاحظات ── */}
|
||||
<p style={sectionHeadingStyle}>ملاحظات</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<Textarea
|
||||
label="ملاحظات"
|
||||
hint="اختياري"
|
||||
error={errors.notes?.message}
|
||||
{...register("notes")}
|
||||
placeholder="أي ملاحظات إضافية…"
|
||||
dir="rtl"
|
||||
className="h-[72px]"
|
||||
/>
|
||||
<Textarea
|
||||
label="سبب الإنهاء"
|
||||
hint="اختياري"
|
||||
error={errors.endReason?.message}
|
||||
{...register("endReason")}
|
||||
placeholder="سبب إنهاء أو إلغاء الرحلة…"
|
||||
dir="rtl"
|
||||
className="h-[72px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* reason — edit only (for reassigning driver/car) */}
|
||||
{!isNew && (
|
||||
<>
|
||||
<p style={sectionHeadingStyle}>سجل التغيير</p>
|
||||
<Input
|
||||
label="سبب التعديل"
|
||||
hint="مطلوب عند تغيير السائق أو السيارة"
|
||||
{...register("reason")}
|
||||
placeholder="مثال: تغيير السائق بسبب إجازة طارئة"
|
||||
dir="rtl"
|
||||
>
|
||||
<option value="">اختر السائق</option>
|
||||
{drivers.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name} — {d.phone}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Car */}
|
||||
<Select
|
||||
label="السيارة *"
|
||||
error={errors.carId?.message}
|
||||
{...register("carId")}
|
||||
dir="rtl"
|
||||
>
|
||||
<option value="">اختر السيارة</option>
|
||||
{cars.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.manufacturer} {c.model} — {c.plateNumber}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
{/* Branch */}
|
||||
<Select
|
||||
label="الفرع *"
|
||||
error={errors.branchId?.message}
|
||||
{...register("branchId")}
|
||||
dir="rtl"
|
||||
>
|
||||
<option value="">اختر الفرع</option>
|
||||
{branches.map((b) => (
|
||||
<option key={b.id} value={b.id}>{b.name}</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
{/* Status */}
|
||||
<Select label="الحالة" {...register("status")} dir="rtl">
|
||||
<option value="Scheduled">مجدولة</option>
|
||||
<option value="InProgress">جارية</option>
|
||||
<option value="Completed">مكتملة</option>
|
||||
<option value="Cancelled">ملغاة</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* ── Section: التوقيت ── */}
|
||||
<p style={sectionHeadingStyle}>التوقيت</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<Input
|
||||
label="وقت البدء"
|
||||
hint="اختياري"
|
||||
type="datetime-local"
|
||||
error={errors.startTime?.message}
|
||||
{...dateTimeField("startTime")}
|
||||
/>
|
||||
<Input
|
||||
label="وقت الانتهاء"
|
||||
hint="اختياري"
|
||||
type="datetime-local"
|
||||
error={errors.endTime?.message}
|
||||
{...dateTimeField("endTime")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Section: الأعداد والمبالغ ── */}
|
||||
<p style={sectionHeadingStyle}>الأعداد والمبالغ</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr 1fr", gap: "0.75rem" }}>
|
||||
<Input
|
||||
label="المجمّع"
|
||||
hint="اختياري"
|
||||
type="number"
|
||||
min="0"
|
||||
error={errors.collectedCount?.message}
|
||||
{...numberField("collectedCount")}
|
||||
placeholder="0"
|
||||
dir="ltr"
|
||||
/>
|
||||
<Input
|
||||
label="المُسلَّم"
|
||||
hint="اختياري"
|
||||
type="number"
|
||||
min="0"
|
||||
error={errors.deliveredCount?.message}
|
||||
{...numberField("deliveredCount")}
|
||||
placeholder="0"
|
||||
dir="ltr"
|
||||
/>
|
||||
<Input
|
||||
label="المُرتجع"
|
||||
hint="اختياري"
|
||||
type="number"
|
||||
min="0"
|
||||
error={errors.returnedCount?.message}
|
||||
{...numberField("returnedCount")}
|
||||
placeholder="0"
|
||||
dir="ltr"
|
||||
/>
|
||||
<Input
|
||||
label="النقد المحصّل"
|
||||
hint="اختياري"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
error={errors.totalCashCollected?.message}
|
||||
{...numberField("totalCashCollected")}
|
||||
placeholder="0.00"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Section: ملاحظات ── */}
|
||||
<p style={sectionHeadingStyle}>ملاحظات</p>
|
||||
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.75rem" }}>
|
||||
<Textarea
|
||||
label="ملاحظات"
|
||||
hint="اختياري"
|
||||
error={errors.notes?.message}
|
||||
{...register("notes")}
|
||||
placeholder="أي ملاحظات إضافية…"
|
||||
dir="rtl"
|
||||
className="h-[72px]"
|
||||
/>
|
||||
<Textarea
|
||||
label="سبب الإنهاء"
|
||||
hint="اختياري"
|
||||
error={errors.endReason?.message}
|
||||
{...register("endReason")}
|
||||
placeholder="سبب إنهاء أو إلغاء الرحلة…"
|
||||
dir="rtl"
|
||||
className="h-[72px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* reason — edit only (for reassigning driver/car) */}
|
||||
{!isNew && (
|
||||
<>
|
||||
<p style={sectionHeadingStyle}>سجل التغيير</p>
|
||||
<Input
|
||||
label="سبب التعديل"
|
||||
hint="مطلوب عند تغيير السائق أو السيارة"
|
||||
{...register("reason")}
|
||||
placeholder="مثال: تغيير السائق بسبب إجازة طارئة"
|
||||
dir="rtl"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Actions ── */}
|
||||
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
|
||||
<Button type="button" variant="secondary" onClick={onClose} disabled={isSubmitting}>
|
||||
إلغاء
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" loading={isSubmitting}>
|
||||
{isSubmitting ? "جارٍ الحفظ…" : isNew ? "إضافة الرحلة" : "حفظ التغييرات"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user