This commit is contained in:
2026-01-23 20:35:40 +03:00
parent cf3b0e48ec
commit 66c151653e
137 changed files with 41495 additions and 0 deletions

View File

@@ -0,0 +1,564 @@
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>
);
}