463 lines
18 KiB
Plaintext
463 lines
18 KiB
Plaintext
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 || '',
|
|
cost: 0,
|
|
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>
|
|
);
|
|
}
|