Files
car_mms/app/components/maintenance-visits/MaintenanceVisitForm.tsx
2026-01-23 20:35:40 +03:00

565 lines
22 KiB
TypeScript
Raw Permalink 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.
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 { useSettings } from "~/contexts/SettingsContext";
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";
const { settings } = useSettings();
// 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);
// Maintenance jobs state (with costs)
const [maintenanceJobs, setMaintenanceJobs] = useState<MaintenanceJob[]>(() => {
if (visit?.maintenanceJobs) {
try {
const jobs = JSON.parse(visit.maintenanceJobs);
return jobs;
} catch {
return [];
}
}
return [];
});
// Current maintenance type being added
const [currentTypeId, setCurrentTypeId] = useState<string>("");
const [currentCost, setCurrentCost] = useState<string>("");
// 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);
setMaintenanceJobs(jobs);
} catch {
setMaintenanceJobs([]);
}
} else {
// Create mode - reset to empty state
setPlateNumberInput("");
setSelectedCustomerId("");
setSelectedVehicleId("");
setMaintenanceJobs([]);
setCurrentTypeId("");
setCurrentCost("");
}
}, [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
};
// Calculate total cost from maintenance jobs
const totalCost = maintenanceJobs.reduce((sum, job) => sum + job.cost, 0);
// Add maintenance job
const handleAddMaintenanceJob = () => {
if (!currentTypeId || !currentCost) return;
const typeIdNum = parseInt(currentTypeId);
const costNum = parseFloat(currentCost);
if (isNaN(typeIdNum) || isNaN(costNum) || costNum <= 0) return;
// Check if type already exists
if (maintenanceJobs.some(job => job.typeId === typeIdNum)) {
alert("هذا النوع من الصيانة موجود بالفعل");
return;
}
const type = maintenanceTypes.find(t => t.id === typeIdNum);
if (!type) return;
const newJob: MaintenanceJob = {
typeId: typeIdNum,
job: type.name,
cost: costNum,
notes: ''
};
setMaintenanceJobs([...maintenanceJobs, newJob]);
setCurrentTypeId("");
setCurrentCost("");
};
// Remove maintenance job
const handleRemoveMaintenanceJob = (typeId: number) => {
setMaintenanceJobs(maintenanceJobs.filter(job => job.typeId !== typeId));
};
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>
<label className="block text-sm font-medium text-gray-700 mb-2">
أنواع الصيانة
<span className="text-red-500 mr-1">*</span>
</label>
{/* Add Maintenance Type Form */}
<div className="flex gap-2 mb-3">
<div className="flex-1">
<select
value={currentTypeId}
onChange={(e) => setCurrentTypeId(e.target.value)}
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"
>
<option value="">اختر نوع الصيانة</option>
{maintenanceTypes
.filter(type => !maintenanceJobs.some(job => job.typeId === type.id))
.map((type) => (
<option key={type.id} value={type.id.toString()}>
{type.name}
</option>
))}
</select>
</div>
<div className="w-32">
<input
type="number"
value={currentCost}
onChange={(e) => setCurrentCost(e.target.value)}
placeholder="التكلفة"
step="0.01"
min="0"
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"
/>
</div>
<Button
type="button"
onClick={handleAddMaintenanceJob}
disabled={!currentTypeId || !currentCost}
size="sm"
>
إضافة
</Button>
</div>
{/* List of Added Maintenance Types */}
{maintenanceJobs.length > 0 && (
<div className="space-y-2 mb-3">
{maintenanceJobs.map((job) => (
<div
key={job.typeId}
className="flex items-center justify-between p-3 bg-gray-50 rounded-md border border-gray-200"
>
<div className="flex-1">
<span className="font-medium">{job.job}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-gray-700">{job.cost.toFixed(2)} {settings.currency}</span>
<button
type="button"
onClick={() => handleRemoveMaintenanceJob(job.typeId)}
className="text-red-600 hover:text-red-800"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
))}
</div>
)}
{maintenanceJobs.length === 0 && (
<Text size="sm" color="secondary" className="mb-3">
لم يتم إضافة أي نوع صيانة بعد
</Text>
)}
{/* Hidden input to pass maintenance jobs data */}
<input
type="hidden"
name="maintenanceJobsData"
value={JSON.stringify(maintenanceJobs)}
/>
{actionData?.errors?.maintenanceJobs && (
<Text size="sm" color="error" className="mt-1">
{actionData.errors.maintenanceJobs}
</Text>
)}
</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={`التكلفة الإجمالية (${settings.currency})`}
value={totalCost.toFixed(2)}
error={actionData?.errors?.cost}
step="0.01"
min="0"
readOnly
className="bg-gray-50"
/>
<Text size="sm" color="secondary" className="mt-1">
يتم حساب التكلفة تلقائياً من أنواع الصيانة المضافة
</Text>
</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.toString(),
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: {maintenanceJobs.length} types<br />
Types: {maintenanceJobs.map(j => j.job).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 = maintenanceJobs.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>
);
}