461 lines
18 KiB
TypeScript
461 lines
18 KiB
TypeScript
import { useState, useEffect } from "react";
|
||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||
import { Button, Input, Select, Text, Card, CardHeader, CardBody, MultiSelect } from "~/components/ui";
|
||
import { AutocompleteInput } from "~/components/ui/AutocompleteInput";
|
||
import type { MaintenanceVisitWithRelations, Customer, Vehicle, MaintenanceType, MaintenanceJob } from "~/types/database";
|
||
import { PAYMENT_STATUS_NAMES, VISIT_DELAY_OPTIONS } from "~/lib/constants";
|
||
|
||
interface MaintenanceVisitFormProps {
|
||
visit?: MaintenanceVisitWithRelations;
|
||
customers: Customer[];
|
||
vehicles: Vehicle[];
|
||
maintenanceTypes: { id: number; name: string; }[];
|
||
onCancel?: () => void;
|
||
}
|
||
|
||
export function MaintenanceVisitForm({
|
||
visit,
|
||
customers,
|
||
vehicles,
|
||
maintenanceTypes,
|
||
onCancel
|
||
}: MaintenanceVisitFormProps) {
|
||
const actionData = useActionData<any>();
|
||
const navigation = useNavigation();
|
||
const isSubmitting = navigation.state === "submitting";
|
||
|
||
// Form state
|
||
const [plateNumberInput, setPlateNumberInput] = useState<string>(
|
||
visit?.vehicle?.plateNumber || ""
|
||
);
|
||
const [selectedCustomerId, setSelectedCustomerId] = useState<string>(
|
||
visit?.customerId?.toString() || ""
|
||
);
|
||
const [selectedVehicleId, setSelectedVehicleId] = useState<string>(
|
||
visit?.vehicleId?.toString() || ""
|
||
);
|
||
const [filteredVehicles, setFilteredVehicles] = useState<Vehicle[]>(vehicles);
|
||
|
||
// Selected maintenance types state
|
||
const [selectedMaintenanceTypes, setSelectedMaintenanceTypes] = useState<number[]>(() => {
|
||
if (visit?.maintenanceJobs) {
|
||
try {
|
||
const jobs = JSON.parse(visit.maintenanceJobs);
|
||
return jobs.map((job: MaintenanceJob) => job.typeId).filter((id: number) => id > 0);
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
return [];
|
||
});
|
||
|
||
// Create autocomplete options for plate numbers
|
||
const plateNumberOptions = vehicles.map(vehicle => {
|
||
const customer = customers.find(c => c.id === vehicle.ownerId);
|
||
return {
|
||
value: vehicle.plateNumber,
|
||
label: `${vehicle.plateNumber} - ${vehicle.manufacturer} ${vehicle.model} (${customer?.name || 'غير محدد'})`,
|
||
data: {
|
||
vehicle,
|
||
customer
|
||
}
|
||
};
|
||
});
|
||
|
||
// Handle plate number selection
|
||
const handlePlateNumberSelect = (option: any) => {
|
||
const { vehicle, customer } = option.data;
|
||
setPlateNumberInput(vehicle.plateNumber);
|
||
setSelectedCustomerId(customer?.id?.toString() || "");
|
||
setSelectedVehicleId(vehicle.id.toString());
|
||
};
|
||
|
||
// Reset form state when visit prop changes (switching between create/edit modes)
|
||
useEffect(() => {
|
||
if (visit) {
|
||
// Editing mode - populate with visit data
|
||
setPlateNumberInput(visit.vehicle?.plateNumber || "");
|
||
setSelectedCustomerId(visit.customerId?.toString() || "");
|
||
setSelectedVehicleId(visit.vehicleId?.toString() || "");
|
||
|
||
// Parse maintenance jobs from JSON
|
||
try {
|
||
const jobs = JSON.parse(visit.maintenanceJobs);
|
||
const typeIds = jobs.map((job: MaintenanceJob) => job.typeId).filter((id: number) => id > 0);
|
||
setSelectedMaintenanceTypes(typeIds);
|
||
} catch {
|
||
setSelectedMaintenanceTypes([]);
|
||
}
|
||
} else {
|
||
// Create mode - reset to empty state
|
||
setPlateNumberInput("");
|
||
setSelectedCustomerId("");
|
||
setSelectedVehicleId("");
|
||
setSelectedMaintenanceTypes([]);
|
||
}
|
||
}, [visit]);
|
||
|
||
// Filter vehicles based on selected customer
|
||
useEffect(() => {
|
||
if (selectedCustomerId) {
|
||
const customerId = parseInt(selectedCustomerId);
|
||
const customerVehicles = vehicles.filter(v => v.ownerId === customerId);
|
||
setFilteredVehicles(customerVehicles);
|
||
|
||
// Reset vehicle selection if current vehicle doesn't belong to selected customer
|
||
if (selectedVehicleId) {
|
||
const vehicleId = parseInt(selectedVehicleId);
|
||
const vehicleBelongsToCustomer = customerVehicles.some(v => v.id === vehicleId);
|
||
if (!vehicleBelongsToCustomer) {
|
||
setSelectedVehicleId("");
|
||
}
|
||
}
|
||
} else {
|
||
setFilteredVehicles(vehicles);
|
||
}
|
||
}, [selectedCustomerId, vehicles, selectedVehicleId]);
|
||
|
||
// Format date for input
|
||
const formatDateForInput = (date: Date | string | null) => {
|
||
if (!date) return "";
|
||
const d = new Date(date);
|
||
return d.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM format
|
||
};
|
||
|
||
// Convert selected maintenance types to jobs format for submission
|
||
const getMaintenanceJobsForSubmission = () => {
|
||
return selectedMaintenanceTypes.map(typeId => {
|
||
const type = maintenanceTypes.find(t => t.id === typeId);
|
||
return {
|
||
typeId,
|
||
job: type?.name || '',
|
||
notes: ''
|
||
};
|
||
});
|
||
};
|
||
|
||
return (
|
||
<Card>
|
||
<CardHeader>
|
||
<Text weight="medium" size="lg">
|
||
{visit ? "تعديل زيارة الصيانة" : "إضافة زيارة صيانة جديدة"}
|
||
</Text>
|
||
</CardHeader>
|
||
<CardBody>
|
||
<Form method="post" className="space-y-6">
|
||
{visit && (
|
||
<input type="hidden" name="id" value={visit.id} />
|
||
)}
|
||
|
||
{/* Plate Number Autocomplete - Only show for new visits */}
|
||
{!visit && (
|
||
<div>
|
||
<AutocompleteInput
|
||
label="رقم اللوحة"
|
||
placeholder="ابدأ بكتابة رقم اللوحة..."
|
||
value={plateNumberInput}
|
||
onChange={setPlateNumberInput}
|
||
onSelect={handlePlateNumberSelect}
|
||
options={plateNumberOptions}
|
||
required
|
||
/>
|
||
<Text size="sm" color="secondary" className="mt-1">
|
||
ابدأ بكتابة رقم اللوحة لاختيار المركبة والعميل تلقائياً
|
||
</Text>
|
||
</div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
{/* Customer Selection */}
|
||
<div>
|
||
<div className="relative">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
العميل
|
||
<span className="text-red-500 mr-1">*</span>
|
||
</label>
|
||
<select
|
||
name="customerId"
|
||
value={selectedCustomerId}
|
||
onChange={(e) => setSelectedCustomerId(e.target.value)}
|
||
required
|
||
disabled={false}
|
||
className={`w-full px-3 py-2 border rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 appearance-none bg-white ${actionData?.errors?.customerId
|
||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||
: 'border-gray-300'
|
||
}`}
|
||
>
|
||
<option value="">اختر العميل</option>
|
||
{customers.map((customer) => (
|
||
<option key={customer.id} value={customer.id.toString()}>
|
||
{customer.name}{customer.phone ? ` - ${customer.phone}` : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none mt-7">
|
||
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
{actionData?.errors?.customerId && (
|
||
<Text size="sm" color="error" className="mt-1">
|
||
{actionData.errors.customerId}
|
||
</Text>
|
||
)}
|
||
{!visit && plateNumberInput && selectedCustomerId && (
|
||
<Text size="sm" color="success" className="mt-1">
|
||
تم اختيار العميل تلقائياً من رقم اللوحة
|
||
</Text>
|
||
)}
|
||
</div>
|
||
|
||
{/* Vehicle Selection */}
|
||
<div>
|
||
<div className="relative">
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
المركبة
|
||
<span className="text-red-500 mr-1">*</span>
|
||
</label>
|
||
<select
|
||
name="vehicleId"
|
||
value={selectedVehicleId}
|
||
onChange={(e) => setSelectedVehicleId(e.target.value)}
|
||
required
|
||
disabled={false}
|
||
className={`w-full px-3 py-2 border rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 appearance-none bg-white ${actionData?.errors?.vehicleId
|
||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||
: 'border-gray-300'
|
||
}`}
|
||
>
|
||
<option value="">اختر المركبة</option>
|
||
{filteredVehicles.map((vehicle) => (
|
||
<option key={vehicle.id} value={vehicle.id.toString()}>
|
||
{vehicle.plateNumber} - {vehicle.manufacturer} {vehicle.model} ({vehicle.year})
|
||
</option>
|
||
))}
|
||
</select>
|
||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none mt-7">
|
||
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
{actionData?.errors?.vehicleId && (
|
||
<Text size="sm" color="error" className="mt-1">
|
||
{actionData.errors.vehicleId}
|
||
</Text>
|
||
)}
|
||
{!selectedCustomerId && !plateNumberInput && (
|
||
<Text size="sm" color="secondary" className="mt-1">
|
||
يرجى اختيار العميل أولاً أو البحث برقم اللوحة
|
||
</Text>
|
||
)}
|
||
{!visit && plateNumberInput && selectedVehicleId && (
|
||
<Text size="sm" color="success" className="mt-1">
|
||
تم اختيار المركبة تلقائياً من رقم اللوحة
|
||
</Text>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Maintenance Types Selection */}
|
||
<div>
|
||
<MultiSelect
|
||
name="maintenanceJobs"
|
||
label="أنواع الصيانة"
|
||
options={maintenanceTypes.map(type => ({
|
||
value: type.id,
|
||
label: type.name
|
||
}))}
|
||
value={selectedMaintenanceTypes}
|
||
onChange={setSelectedMaintenanceTypes}
|
||
placeholder="اختر أنواع الصيانة المطلوبة..."
|
||
error={actionData?.errors?.maintenanceJobs}
|
||
required
|
||
/>
|
||
<Text size="sm" color="secondary" className="mt-1">
|
||
يمكنك اختيار أكثر من نوع صيانة واحد
|
||
</Text>
|
||
|
||
{/* Hidden input to pass maintenance jobs data in the expected format */}
|
||
<input
|
||
type="hidden"
|
||
name="maintenanceJobsData"
|
||
value={JSON.stringify(getMaintenanceJobsForSubmission())}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-1 gap-6">
|
||
{/* Payment Status */}
|
||
<div>
|
||
<Select
|
||
name="paymentStatus"
|
||
label="حالة الدفع"
|
||
defaultValue={visit?.paymentStatus || "pending"}
|
||
error={actionData?.errors?.paymentStatus}
|
||
required
|
||
options={Object.entries(PAYMENT_STATUS_NAMES).map(([value, label]) => ({
|
||
value: value,
|
||
label: label
|
||
}))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Description */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
وصف الصيانة
|
||
<span className="text-red-500 mr-1">*</span>
|
||
</label>
|
||
<textarea
|
||
name="description"
|
||
rows={3}
|
||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||
defaultValue={visit?.description || ""}
|
||
placeholder="اكتب وصف تفصيلي للأعمال المنجزة..."
|
||
required
|
||
/>
|
||
{actionData?.errors?.description && (
|
||
<Text size="sm" color="error" className="mt-1">
|
||
{actionData.errors.description}
|
||
</Text>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||
{/* Cost */}
|
||
<div>
|
||
<Input
|
||
type="number"
|
||
name="cost"
|
||
label="التكلفة (ريال)"
|
||
defaultValue={visit?.cost?.toString() || ""}
|
||
error={actionData?.errors?.cost}
|
||
step="0.01"
|
||
min="0"
|
||
max="999999.99"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{/* Kilometers */}
|
||
<div>
|
||
<Input
|
||
type="number"
|
||
name="kilometers"
|
||
label="عدد الكيلومترات"
|
||
defaultValue={visit?.kilometers?.toString() || ""}
|
||
error={actionData?.errors?.kilometers}
|
||
min="0"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{/* Next Visit Delay */}
|
||
<div>
|
||
<Select
|
||
name="nextVisitDelay"
|
||
label="الزيارة التالية بعد"
|
||
defaultValue={visit?.nextVisitDelay?.toString() || "3"}
|
||
error={actionData?.errors?.nextVisitDelay}
|
||
required
|
||
options={VISIT_DELAY_OPTIONS.map((option) => ({
|
||
value: option.value,
|
||
label: option.label
|
||
}))}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Visit Date */}
|
||
<div>
|
||
<Input
|
||
type="datetime-local"
|
||
name="visitDate"
|
||
label="تاريخ ووقت الزيارة"
|
||
defaultValue={formatDateForInput(visit?.visitDate || new Date())}
|
||
error={actionData?.errors?.visitDate}
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
{/* Action Buttons */}
|
||
{/* Debug Info */}
|
||
{!visit && (
|
||
<div className="bg-gray-50 p-3 rounded text-xs">
|
||
<strong>Debug Info:</strong><br />
|
||
Customer ID: {selectedCustomerId || "Not selected"}<br />
|
||
Vehicle ID: {selectedVehicleId || "Not selected"}<br />
|
||
Plate Number: {plateNumberInput || "Not entered"}<br />
|
||
Selected Maintenance Types: {selectedMaintenanceTypes.length} types<br />
|
||
Types: {selectedMaintenanceTypes.join(', ') || "None selected"}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex justify-end gap-3 pt-4">
|
||
{onCancel && (
|
||
<Button
|
||
type="button"
|
||
variant="secondary"
|
||
onClick={onCancel}
|
||
disabled={isSubmitting}
|
||
>
|
||
إلغاء
|
||
</Button>
|
||
)}
|
||
<Button
|
||
type="submit"
|
||
name="intent"
|
||
value={visit ? "update" : "create"}
|
||
disabled={isSubmitting}
|
||
onClick={(e) => {
|
||
// Client-side validation before submission
|
||
if (!visit) {
|
||
const form = e.currentTarget.form;
|
||
if (!form) return;
|
||
|
||
const formData = new FormData(form);
|
||
const customerId = formData.get("customerId") as string;
|
||
const vehicleId = formData.get("vehicleId") as string;
|
||
const description = formData.get("description") as string;
|
||
const cost = formData.get("cost") as string;
|
||
const kilometers = formData.get("kilometers") as string;
|
||
|
||
const hasValidCustomer = customerId && customerId !== "";
|
||
const hasValidVehicle = vehicleId && vehicleId !== "";
|
||
const hasValidJobs = selectedMaintenanceTypes.length > 0;
|
||
const hasValidDescription = description && description.trim() !== "";
|
||
const hasValidCost = cost && cost.trim() !== "" && parseFloat(cost) > 0;
|
||
const hasValidKilometers = kilometers && kilometers.trim() !== "" && parseInt(kilometers) >= 0;
|
||
|
||
const missingFields = [];
|
||
if (!hasValidCustomer) missingFields.push("العميل");
|
||
if (!hasValidVehicle) missingFields.push("المركبة");
|
||
if (!hasValidJobs) missingFields.push("نوع صيانة واحد على الأقل");
|
||
if (!hasValidDescription) missingFields.push("وصف الصيانة");
|
||
if (!hasValidCost) missingFields.push("التكلفة");
|
||
if (!hasValidKilometers) missingFields.push("عدد الكيلومترات");
|
||
|
||
if (missingFields.length > 0) {
|
||
e.preventDefault();
|
||
alert(`يرجى ملء الحقول المطلوبة التالية:\n- ${missingFields.join('\n- ')}`);
|
||
return;
|
||
}
|
||
}
|
||
}}
|
||
>
|
||
{isSubmitting
|
||
? visit
|
||
? "جاري التحديث..."
|
||
: "جاري الحفظ..."
|
||
: visit
|
||
? "تحديث الزيارة"
|
||
: "حفظ الزيارة"}
|
||
</Button>
|
||
</div>
|
||
</Form>
|
||
</CardBody>
|
||
</Card>
|
||
);
|
||
} |