uup
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { useSettings } from "~/contexts/SettingsContext";
|
||||
import type { MaintenanceVisitWithRelations } from "~/types/database";
|
||||
|
||||
interface MaintenanceVisitDetailsViewProps {
|
||||
visit: MaintenanceVisitWithRelations;
|
||||
}
|
||||
|
||||
export function MaintenanceVisitDetailsView({ visit }: MaintenanceVisitDetailsViewProps) {
|
||||
const { formatCurrency, formatDate, formatNumber } = useSettings();
|
||||
const getPaymentStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'partial':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getPaymentStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'مدفوع';
|
||||
case 'pending': return 'معلق';
|
||||
case 'partial': return 'مدفوع جزئياً';
|
||||
case 'cancelled': return 'ملغي';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
const getDelayLabel = (months: number) => {
|
||||
const delayOptions = [
|
||||
{ value: 1, label: 'شهر واحد' },
|
||||
{ value: 2, label: 'شهرين' },
|
||||
{ value: 3, label: '3 أشهر' },
|
||||
{ value: 4, label: '4 أشهر' },
|
||||
{ value: 6, label: '6 أشهر' },
|
||||
{ value: 12, label: 'سنة واحدة' },
|
||||
];
|
||||
const option = delayOptions.find(opt => opt.value === months);
|
||||
return option ? option.label : `${months} أشهر`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header Section */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-100">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<span className="text-3xl ml-3">🔧</span>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
زيارة صيانة #{visit.id}
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{formatDate(visit.visitDate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-blue-600 mb-1">
|
||||
{formatCurrency(visit.cost)}
|
||||
</div>
|
||||
<span className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full border ${getPaymentStatusColor(visit.paymentStatus)}`}>
|
||||
{getPaymentStatusLabel(visit.paymentStatus)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Details Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Visit Information */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<span className="text-gray-600 text-xl ml-2">📋</span>
|
||||
تفاصيل الزيارة
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-3">أعمال الصيانة المنجزة</label>
|
||||
<div className="space-y-3">
|
||||
{(() => {
|
||||
try {
|
||||
const jobs = JSON.parse(visit.maintenanceJobs);
|
||||
return jobs.map((job: any, index: number) => (
|
||||
<div key={index} className="bg-white p-3 rounded-lg border border-blue-200">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-gray-900 mb-1">{job.job}</p>
|
||||
{job.notes && (
|
||||
<p className="text-sm text-gray-600">{job.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded-full">
|
||||
#{index + 1}
|
||||
</span>
|
||||
{job.cost !== undefined && (
|
||||
<span className="text-sm font-bold text-gray-700">
|
||||
{formatCurrency(job.cost)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
} catch {
|
||||
return (
|
||||
<div className="bg-white p-3 rounded-lg border border-blue-200">
|
||||
<p className="text-gray-900">لا توجد تفاصيل أعمال الصيانة</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">عداد الكيلومترات</label>
|
||||
<p className="text-gray-900 font-mono text-lg">
|
||||
{formatNumber(visit.kilometers)} كم
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">الزيارة التالية بعد</label>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{getDelayLabel(visit.nextVisitDelay)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-2">وصف الأعمال المنجزة</label>
|
||||
<div className="bg-white rounded-lg p-4 border">
|
||||
<p className="text-gray-900 whitespace-pre-wrap leading-relaxed">
|
||||
{visit.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vehicle & Customer Information */}
|
||||
<div className="space-y-6">
|
||||
{/* Vehicle Info */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<span className="text-gray-600 text-xl ml-2">🚗</span>
|
||||
معلومات المركبة
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">رقم اللوحة</label>
|
||||
<p className="text-xl font-bold text-gray-900 font-mono">
|
||||
{visit.vehicle.plateNumber}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">السنة</label>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{visit.vehicle.year}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">الشركة المصنعة</label>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{visit.vehicle.manufacturer}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">الموديل</label>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{visit.vehicle.model}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Info */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<span className="text-gray-600 text-xl ml-2">👤</span>
|
||||
معلومات العميل
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">اسم العميل</label>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{visit.customer.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(visit.customer.phone || visit.customer.email) && (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{visit.customer.phone && (
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">رقم الهاتف</label>
|
||||
<a
|
||||
href={`tel:${visit.customer.phone}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-mono font-medium"
|
||||
dir="ltr"
|
||||
>
|
||||
📞 {visit.customer.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visit.customer.email && (
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">البريد الإلكتروني</label>
|
||||
<a
|
||||
href={`mailto:${visit.customer.email}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
dir="ltr"
|
||||
>
|
||||
✉️ {visit.customer.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visit.customer.address && (
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">العنوان</label>
|
||||
<p className="text-gray-900">
|
||||
{visit.customer.address}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Income Information */}
|
||||
{visit.income && visit.income.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<span className="text-gray-600 text-xl ml-2">💰</span>
|
||||
سجل الدخل
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-3">
|
||||
{visit.income.map((income) => (
|
||||
<div key={income.id} className="flex justify-between items-center bg-green-50 p-4 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">تاريخ الدخل</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{formatDate(income.incomeDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-600">المبلغ</p>
|
||||
<p className="text-xl font-bold text-green-600 font-mono">
|
||||
{formatCurrency(income.amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-3 pt-6 border-t border-gray-200">
|
||||
<Link
|
||||
to={`/vehicles?search=${encodeURIComponent(visit.vehicle.plateNumber)}`}
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<span className="ml-2">🚗</span>
|
||||
عرض تفاصيل المركبة
|
||||
</Link>
|
||||
<Link
|
||||
to={`/customers?search=${encodeURIComponent(visit.customer.name)}`}
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-green-600 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
|
||||
>
|
||||
<span className="ml-2">👤</span>
|
||||
عرض تفاصيل العميل
|
||||
</Link>
|
||||
<Link
|
||||
to={`/maintenance-visits?vehicleId=${visit.vehicle.id}`}
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-purple-600 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
|
||||
>
|
||||
<span className="ml-2">📋</span>
|
||||
جميع زيارات هذه المركبة
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
564
app/components/maintenance-visits/MaintenanceVisitForm.tsx
Normal file
564
app/components/maintenance-visits/MaintenanceVisitForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
253
app/components/maintenance-visits/MaintenanceVisitList.tsx
Normal file
253
app/components/maintenance-visits/MaintenanceVisitList.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useState } from "react";
|
||||
import { Link, Form } from "@remix-run/react";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Text } from "~/components/ui/Text";
|
||||
import { DataTable } from "~/components/ui/DataTable";
|
||||
import type { MaintenanceVisitWithRelations } from "~/types/database";
|
||||
import { PAYMENT_STATUS_NAMES } from "~/lib/constants";
|
||||
import { useSettings } from "~/contexts/SettingsContext";
|
||||
|
||||
interface MaintenanceVisitListProps {
|
||||
visits: MaintenanceVisitWithRelations[];
|
||||
onEdit?: (visit: MaintenanceVisitWithRelations) => void;
|
||||
onView?: (visit: MaintenanceVisitWithRelations) => void;
|
||||
}
|
||||
|
||||
export function MaintenanceVisitList({
|
||||
visits,
|
||||
onEdit,
|
||||
onView
|
||||
}: MaintenanceVisitListProps) {
|
||||
const { formatDate, formatCurrency, formatNumber, formatDateTime } = useSettings();
|
||||
const [deleteVisitId, setDeleteVisitId] = useState<number | null>(null);
|
||||
|
||||
const handleDelete = (visitId: number) => {
|
||||
setDeleteVisitId(visitId);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteVisitId) {
|
||||
// Submit delete form
|
||||
const form = document.createElement('form');
|
||||
form.method = 'post';
|
||||
form.style.display = 'none';
|
||||
|
||||
const intentInput = document.createElement('input');
|
||||
intentInput.type = 'hidden';
|
||||
intentInput.name = 'intent';
|
||||
intentInput.value = 'delete';
|
||||
|
||||
const idInput = document.createElement('input');
|
||||
idInput.type = 'hidden';
|
||||
idInput.name = 'id';
|
||||
idInput.value = deleteVisitId.toString();
|
||||
|
||||
form.appendChild(intentInput);
|
||||
form.appendChild(idInput);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
};
|
||||
|
||||
const getPaymentStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return 'text-green-600 bg-green-50';
|
||||
case 'pending':
|
||||
return 'text-yellow-600 bg-yellow-50';
|
||||
case 'partial':
|
||||
return 'text-blue-600 bg-blue-50';
|
||||
case 'cancelled':
|
||||
return 'text-red-600 bg-red-50';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'visitDate',
|
||||
header: 'تاريخ الزيارة',
|
||||
render: (visit: MaintenanceVisitWithRelations) => (
|
||||
<div>
|
||||
<Text weight="medium">
|
||||
{formatDate(visit.visitDate)}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{formatDateTime(visit.visitDate).split(' ')[1]}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'vehicle',
|
||||
header: 'المركبة',
|
||||
render: (visit: MaintenanceVisitWithRelations) => (
|
||||
<div>
|
||||
<Text weight="medium">{visit.vehicle.plateNumber}</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{visit.vehicle.manufacturer} {visit.vehicle.model} ({visit.vehicle.year})
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'customer',
|
||||
header: 'العميل',
|
||||
render: (visit: MaintenanceVisitWithRelations) => (
|
||||
<div>
|
||||
<Text weight="medium">{visit.customer.name}</Text>
|
||||
{visit.customer.phone && (
|
||||
<Text size="sm" color="secondary">{visit.customer.phone}</Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'maintenanceJobs',
|
||||
header: 'أعمال الصيانة',
|
||||
render: (visit: MaintenanceVisitWithRelations) => {
|
||||
try {
|
||||
const jobs = JSON.parse(visit.maintenanceJobs);
|
||||
return (
|
||||
<div>
|
||||
<Text weight="medium">
|
||||
{jobs.length > 1 ? `${jobs.length} أعمال صيانة` : jobs[0]?.job || 'غير محدد'}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary" className="line-clamp-2">
|
||||
{visit.description}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return (
|
||||
<div>
|
||||
<Text weight="medium">غير محدد</Text>
|
||||
<Text size="sm" color="secondary" className="line-clamp-2">
|
||||
{visit.description}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'cost',
|
||||
header: 'التكلفة',
|
||||
render: (visit: MaintenanceVisitWithRelations) => (
|
||||
<Text weight="medium" className="font-mono">
|
||||
{formatCurrency(visit.cost)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'paymentStatus',
|
||||
header: 'حالة الدفع',
|
||||
render: (visit: MaintenanceVisitWithRelations) => (
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getPaymentStatusColor(visit.paymentStatus)}`}>
|
||||
{PAYMENT_STATUS_NAMES[visit.paymentStatus as keyof typeof PAYMENT_STATUS_NAMES]}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'kilometers',
|
||||
header: 'الكيلومترات',
|
||||
render: (visit: MaintenanceVisitWithRelations) => (
|
||||
<Text className="font-mono">
|
||||
{formatNumber(visit.kilometers)} كم
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'الإجراءات',
|
||||
render: (visit: MaintenanceVisitWithRelations) => (
|
||||
<div className="flex gap-2">
|
||||
{onView ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onView(visit)}
|
||||
>
|
||||
عرض
|
||||
</Button>
|
||||
) : (
|
||||
<Link to={`/maintenance-visits/${visit.id}`}>
|
||||
<Button size="sm" variant="outline">
|
||||
عرض
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onEdit(visit)}
|
||||
>
|
||||
تعديل
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-red-600 border-red-300 hover:bg-red-50"
|
||||
onClick={() => handleDelete(visit.id)}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-lg shadow-sm border">
|
||||
{visits.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 text-lg mb-2">🔧</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
لا توجد زيارات صيانة
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
لم يتم العثور على أي زيارات صيانة. قم بإضافة زيارة جديدة للبدء.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={visits}
|
||||
columns={columns}
|
||||
emptyMessage="لم يتم العثور على أي زيارات صيانة"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteVisitId !== null && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">تأكيد الحذف</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
هل أنت متأكد من حذف زيارة الصيانة هذه؟ سيتم حذف جميع البيانات المرتبطة بها نهائياً.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteVisitId(null)}
|
||||
>
|
||||
إلغاء
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-red-600 border-red-300 hover:bg-red-50"
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
app/components/maintenance-visits/index.ts
Normal file
2
app/components/maintenance-visits/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MaintenanceVisitForm } from './MaintenanceVisitForm';
|
||||
export { MaintenanceVisitList } from './MaintenanceVisitList';
|
||||
Reference in New Issue
Block a user