uup
This commit is contained in:
552
app/routes/maintenance-visits.tsx
Normal file
552
app/routes/maintenance-visits.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { LoaderFunctionArgs, ActionFunctionArgs, MetaFunction } from "@remix-run/node";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { json, redirect } from "@remix-run/node";
|
||||
import { useLoaderData, useActionData, useSearchParams, useNavigation } from "@remix-run/react";
|
||||
import { useSettings } from "~/contexts/SettingsContext";
|
||||
import { protectMaintenanceRoute } from "~/lib/auth-middleware.server";
|
||||
import { DashboardLayout } from "~/components/layout/DashboardLayout";
|
||||
import { Text } from "~/components/ui/Text";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Modal } from "~/components/ui/Modal";
|
||||
import { Input } from "~/components/ui/Input";
|
||||
import { Select } from "~/components/ui/Select";
|
||||
import { Flex } from "~/components/layout/Flex";
|
||||
import { MaintenanceVisitList } from "~/components/maintenance-visits/MaintenanceVisitList";
|
||||
import { MaintenanceVisitForm } from "~/components/maintenance-visits/MaintenanceVisitForm";
|
||||
import { MaintenanceVisitDetailsView } from "~/components/maintenance-visits/MaintenanceVisitDetailsView";
|
||||
import {
|
||||
getMaintenanceVisits,
|
||||
createMaintenanceVisit,
|
||||
updateMaintenanceVisit,
|
||||
deleteMaintenanceVisit,
|
||||
getMaintenanceVisitById
|
||||
} from "~/lib/maintenance-visit-management.server";
|
||||
import { getCustomers } from "~/lib/customer-management.server";
|
||||
import { getVehicles } from "~/lib/vehicle-management.server";
|
||||
import { getMaintenanceTypesForSelect } from "~/lib/maintenance-type-management.server";
|
||||
import { validateMaintenanceVisit } from "~/lib/validation";
|
||||
import type { MaintenanceVisitWithRelations } from "~/types/database";
|
||||
import { PAGINATION } from "~/lib/constants";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "زيارات الصيانة - نظام إدارة صيانة السيارات" },
|
||||
{ name: "description", content: "إدارة زيارات الصيانة وتسجيل الأعمال المنجزة" },
|
||||
];
|
||||
};
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const user = await protectMaintenanceRoute(request);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const searchQuery = url.searchParams.get("search") || "";
|
||||
const paymentStatusFilter = url.searchParams.get("paymentStatus") || "";
|
||||
const customerId = url.searchParams.get("customerId") ? parseInt(url.searchParams.get("customerId")!) : undefined;
|
||||
const vehicleId = url.searchParams.get("vehicleId") ? parseInt(url.searchParams.get("vehicleId")!) : undefined;
|
||||
const page = parseInt(url.searchParams.get("page") || "1");
|
||||
const limit = parseInt(url.searchParams.get("limit") || PAGINATION.DEFAULT_PAGE_SIZE.toString());
|
||||
|
||||
// Get maintenance visits with filters
|
||||
const { visits, total, totalPages } = await getMaintenanceVisits(
|
||||
searchQuery,
|
||||
page,
|
||||
limit,
|
||||
vehicleId,
|
||||
customerId
|
||||
);
|
||||
|
||||
// Get customers, vehicles, and maintenance types for the form
|
||||
const { customers } = await getCustomers("", 1, 1000); // Get all customers
|
||||
const { vehicles } = await getVehicles("", 1, 1000); // Get all vehicles
|
||||
const maintenanceTypes = await getMaintenanceTypesForSelect(); // Get all maintenance types
|
||||
|
||||
return json({
|
||||
user,
|
||||
visits,
|
||||
customers,
|
||||
vehicles,
|
||||
maintenanceTypes,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
},
|
||||
searchQuery,
|
||||
paymentStatusFilter,
|
||||
customerId,
|
||||
vehicleId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const user = await protectMaintenanceRoute(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
const intent = formData.get("intent") as string;
|
||||
|
||||
try {
|
||||
switch (intent) {
|
||||
case "create": {
|
||||
// Debug: Log all form data
|
||||
console.log("Form data received:");
|
||||
for (const [key, value] of formData.entries()) {
|
||||
console.log(`${key}: ${value}`);
|
||||
}
|
||||
|
||||
// Check if the required fields are missing from form data
|
||||
if (!formData.has("customerId")) {
|
||||
console.error("customerId field is missing from form data!");
|
||||
return json({
|
||||
success: false,
|
||||
errors: { customerId: "العميل مطلوب" }
|
||||
}, { status: 400 });
|
||||
}
|
||||
if (!formData.has("vehicleId")) {
|
||||
console.error("vehicleId field is missing from form data!");
|
||||
return json({
|
||||
success: false,
|
||||
errors: { vehicleId: "المركبة مطلوبة" }
|
||||
}, { status: 400 });
|
||||
}
|
||||
if (!formData.has("description")) {
|
||||
console.error("description field is missing from form data!");
|
||||
return json({
|
||||
success: false,
|
||||
errors: { description: "وصف الصيانة مطلوب" }
|
||||
}, { status: 400 });
|
||||
}
|
||||
if (!formData.has("cost")) {
|
||||
console.error("cost field is missing from form data!");
|
||||
return json({
|
||||
success: false,
|
||||
errors: { cost: "التكلفة مطلوبة" }
|
||||
}, { status: 400 });
|
||||
}
|
||||
if (!formData.has("kilometers")) {
|
||||
console.error("kilometers field is missing from form data!");
|
||||
return json({
|
||||
success: false,
|
||||
errors: { kilometers: "عدد الكيلومترات مطلوب" }
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
const vehicleIdRaw = formData.get("vehicleId") as string;
|
||||
const customerIdRaw = formData.get("customerId") as string;
|
||||
const maintenanceJobsRaw = formData.get("maintenanceJobsData") as string;
|
||||
const costRaw = formData.get("cost") as string;
|
||||
const kilometersRaw = formData.get("kilometers") as string;
|
||||
const nextVisitDelayRaw = formData.get("nextVisitDelay") as string;
|
||||
|
||||
console.log("Raw values:", {
|
||||
vehicleIdRaw,
|
||||
customerIdRaw,
|
||||
maintenanceJobsRaw,
|
||||
costRaw,
|
||||
kilometersRaw,
|
||||
nextVisitDelayRaw
|
||||
});
|
||||
|
||||
// Parse maintenance jobs
|
||||
let maintenanceJobs;
|
||||
try {
|
||||
maintenanceJobs = JSON.parse(maintenanceJobsRaw || "[]");
|
||||
// Jobs are already in the correct format from the form
|
||||
} catch {
|
||||
maintenanceJobs = [];
|
||||
}
|
||||
|
||||
// Check for empty strings and convert them to undefined for proper validation
|
||||
const data = {
|
||||
vehicleId: vehicleIdRaw && vehicleIdRaw.trim() !== "" ? parseInt(vehicleIdRaw) : undefined,
|
||||
customerId: customerIdRaw && customerIdRaw.trim() !== "" ? parseInt(customerIdRaw) : undefined,
|
||||
maintenanceJobs,
|
||||
description: formData.get("description") as string,
|
||||
cost: costRaw && costRaw.trim() !== "" ? parseFloat(costRaw) : undefined,
|
||||
paymentStatus: formData.get("paymentStatus") as string,
|
||||
kilometers: kilometersRaw && kilometersRaw.trim() !== "" ? parseInt(kilometersRaw) : undefined,
|
||||
nextVisitDelay: nextVisitDelayRaw && nextVisitDelayRaw.trim() !== "" ? parseInt(nextVisitDelayRaw) : undefined,
|
||||
visitDate: formData.get("visitDate") ? new Date(formData.get("visitDate") as string) : new Date(),
|
||||
};
|
||||
|
||||
console.log("Parsed data:", data);
|
||||
|
||||
const validation = validateMaintenanceVisit(data);
|
||||
console.log("Validation result:", validation);
|
||||
|
||||
if (!validation.isValid) {
|
||||
return json({ success: false, errors: validation.errors }, { status: 400 });
|
||||
}
|
||||
|
||||
await createMaintenanceVisit(data);
|
||||
return json({ success: true, message: "تم إنشاء زيارة الصيانة بنجاح" });
|
||||
}
|
||||
|
||||
case "update": {
|
||||
const id = parseInt(formData.get("id") as string);
|
||||
const maintenanceJobsRaw = formData.get("maintenanceJobsData") as string;
|
||||
|
||||
// Parse maintenance jobs
|
||||
let maintenanceJobs;
|
||||
try {
|
||||
maintenanceJobs = JSON.parse(maintenanceJobsRaw || "[]");
|
||||
// Jobs are already in the correct format from the form
|
||||
} catch {
|
||||
maintenanceJobs = [];
|
||||
}
|
||||
|
||||
const data = {
|
||||
maintenanceJobs,
|
||||
description: formData.get("description") as string,
|
||||
cost: parseFloat(formData.get("cost") as string),
|
||||
paymentStatus: formData.get("paymentStatus") as string,
|
||||
kilometers: parseInt(formData.get("kilometers") as string),
|
||||
nextVisitDelay: parseInt(formData.get("nextVisitDelay") as string),
|
||||
visitDate: formData.get("visitDate") ? new Date(formData.get("visitDate") as string) : undefined,
|
||||
};
|
||||
|
||||
const validation = validateMaintenanceVisit(data);
|
||||
if (!validation.isValid) {
|
||||
return json({ success: false, errors: validation.errors }, { status: 400 });
|
||||
}
|
||||
|
||||
await updateMaintenanceVisit(id, data);
|
||||
return json({ success: true, message: "تم تحديث زيارة الصيانة بنجاح" });
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
const id = parseInt(formData.get("id") as string);
|
||||
await deleteMaintenanceVisit(id);
|
||||
return json({ success: true, message: "تم حذف زيارة الصيانة بنجاح" });
|
||||
}
|
||||
|
||||
default:
|
||||
return json({ success: false, error: "إجراء غير صحيح" }, { status: 400 });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Maintenance visit action error:", error);
|
||||
return json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "حدث خطأ غير متوقع"
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function MaintenanceVisits() {
|
||||
const {
|
||||
user,
|
||||
visits,
|
||||
customers,
|
||||
vehicles,
|
||||
maintenanceTypes,
|
||||
pagination,
|
||||
searchQuery,
|
||||
paymentStatusFilter,
|
||||
customerId,
|
||||
vehicleId
|
||||
} = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<any>();
|
||||
const navigation = useNavigation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [showViewModal, setShowViewModal] = useState(false);
|
||||
const [editingVisit, setEditingVisit] = useState<MaintenanceVisitWithRelations | null>(null);
|
||||
const [viewingVisit, setViewingVisit] = useState<MaintenanceVisitWithRelations | null>(null);
|
||||
const [searchValue, setSearchValue] = useState(searchQuery);
|
||||
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState(paymentStatusFilter);
|
||||
const [justOpenedForm, setJustOpenedForm] = useState(false);
|
||||
|
||||
// Debounce search values to avoid too many requests
|
||||
const debouncedSearchValue = useDebounce(searchValue, 300);
|
||||
const debouncedPaymentStatus = useDebounce(selectedPaymentStatus, 300);
|
||||
|
||||
const handleEdit = (visit: MaintenanceVisitWithRelations) => {
|
||||
console.log("Opening edit form for visit:", visit.id);
|
||||
setEditingVisit(visit);
|
||||
setJustOpenedForm(true);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleView = (visit: MaintenanceVisitWithRelations) => {
|
||||
setViewingVisit(visit);
|
||||
setShowViewModal(true);
|
||||
};
|
||||
|
||||
const handleCloseForm = () => {
|
||||
setShowForm(false);
|
||||
setEditingVisit(null);
|
||||
};
|
||||
|
||||
const handleOpenCreateForm = () => {
|
||||
console.log("Opening create form");
|
||||
setEditingVisit(null);
|
||||
setJustOpenedForm(true);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleCloseViewModal = () => {
|
||||
setShowViewModal(false);
|
||||
setViewingVisit(null);
|
||||
};
|
||||
|
||||
// Handle search automatically when debounced values change
|
||||
useEffect(() => {
|
||||
if (debouncedSearchValue !== searchQuery || debouncedPaymentStatus !== paymentStatusFilter) {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
if (debouncedSearchValue) {
|
||||
newSearchParams.set("search", debouncedSearchValue);
|
||||
} else {
|
||||
newSearchParams.delete("search");
|
||||
}
|
||||
if (debouncedPaymentStatus) {
|
||||
newSearchParams.set("paymentStatus", debouncedPaymentStatus);
|
||||
} else {
|
||||
newSearchParams.delete("paymentStatus");
|
||||
}
|
||||
newSearchParams.set("page", "1"); // Reset to first page
|
||||
setSearchParams(newSearchParams);
|
||||
}
|
||||
}, [debouncedSearchValue, debouncedPaymentStatus, searchQuery, paymentStatusFilter, searchParams, setSearchParams]);
|
||||
|
||||
// Clear search function
|
||||
const clearSearch = () => {
|
||||
setSearchValue("");
|
||||
setSelectedPaymentStatus("");
|
||||
};
|
||||
|
||||
// Handle pagination
|
||||
const handlePageChange = (page: number) => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.set("page", page.toString());
|
||||
setSearchParams(newSearchParams);
|
||||
};
|
||||
|
||||
// Track when we've just completed a form submission
|
||||
const [wasSubmitting, setWasSubmitting] = useState(false);
|
||||
|
||||
// Track navigation state changes
|
||||
useEffect(() => {
|
||||
if (navigation.state === "submitting") {
|
||||
setWasSubmitting(true);
|
||||
} else if (navigation.state === "idle" && wasSubmitting) {
|
||||
// We just finished submitting
|
||||
setWasSubmitting(false);
|
||||
|
||||
// Close form only if the submission was successful
|
||||
if (actionData?.success && showForm) {
|
||||
console.log("Closing form after successful submission");
|
||||
setShowForm(false);
|
||||
setEditingVisit(null);
|
||||
}
|
||||
}
|
||||
}, [navigation.state, wasSubmitting, actionData?.success, showForm]);
|
||||
|
||||
// Reset the justOpenedForm flag after a short delay
|
||||
useEffect(() => {
|
||||
if (justOpenedForm) {
|
||||
console.log("Setting timer to reset justOpenedForm flag");
|
||||
const timer = setTimeout(() => {
|
||||
console.log("Resetting justOpenedForm flag");
|
||||
setJustOpenedForm(false);
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [justOpenedForm]);
|
||||
|
||||
return (
|
||||
<DashboardLayout user={user}>
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<Text as="h1" size="2xl" weight="bold" className="text-gray-900">
|
||||
زيارات الصيانة
|
||||
</Text>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<Text color="secondary">
|
||||
إدارة زيارات الصيانة وتسجيل الأعمال المنجزة
|
||||
</Text>
|
||||
{customerId && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded-full">
|
||||
مفلترة حسب العميل
|
||||
</span>
|
||||
)}
|
||||
{vehicleId && (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">
|
||||
مفلترة حسب المركبة
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={handleOpenCreateForm}>
|
||||
إضافة زيارة صيانة
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
{actionData?.success && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-md p-4">
|
||||
<Text color="success">{actionData.message}</Text>
|
||||
</div>
|
||||
)}
|
||||
{actionData?.error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||
<Text color="error">{actionData.error}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm border">
|
||||
<Flex gap={4} align="center" className="flex-wrap">
|
||||
<div className="flex-1 min-w-64">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="البحث في زيارات الصيانة... (البحث تلقائي)"
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
startIcon={
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
}
|
||||
endIcon={
|
||||
searchValue && (
|
||||
<button
|
||||
onClick={() => setSearchValue("")}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
type="button"
|
||||
>
|
||||
<svg className="h-4 w-4" 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 className="min-w-48">
|
||||
<Select
|
||||
value={selectedPaymentStatus}
|
||||
onChange={(e) => setSelectedPaymentStatus(e.target.value)}
|
||||
options={[
|
||||
{ value: "", label: "جميع حالات الدفع" },
|
||||
{ value: "paid", label: "مدفوع" },
|
||||
{ value: "pending", label: "معلق" },
|
||||
{ value: "partial", label: "مدفوع جزئياً" },
|
||||
{ value: "cancelled", label: "ملغي" },
|
||||
]}
|
||||
placeholder="جميع حالات الدفع"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(searchQuery || paymentStatusFilter || debouncedSearchValue !== searchQuery || debouncedPaymentStatus !== paymentStatusFilter) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{(debouncedSearchValue !== searchQuery || debouncedPaymentStatus !== paymentStatusFilter) && (
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
جاري البحث...
|
||||
</div>
|
||||
)}
|
||||
{(searchQuery || paymentStatusFilter) && (
|
||||
<Button
|
||||
onClick={clearSearch}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
مسح البحث
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
{/* Maintenance Visits List */}
|
||||
<MaintenanceVisitList
|
||||
visits={visits}
|
||||
onEdit={handleEdit}
|
||||
onView={handleView}
|
||||
/>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex justify-center">
|
||||
<div className="flex items-center space-x-2 space-x-reverse">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(pagination.page - 1)}
|
||||
disabled={pagination.page === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-1 space-x-reverse">
|
||||
{Array.from({ length: Math.min(5, pagination.totalPages) }, (_, i) => {
|
||||
const page = i + 1;
|
||||
return (
|
||||
<Button
|
||||
key={page}
|
||||
variant={pagination.page === page ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(page)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePageChange(pagination.page + 1)}
|
||||
disabled={pagination.page === pagination.totalPages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Modal */}
|
||||
<Modal
|
||||
isOpen={showForm}
|
||||
onClose={handleCloseForm}
|
||||
title={editingVisit ? "تعديل زيارة الصيانة" : "إضافة زيارة صيانة جديدة"}
|
||||
size="lg"
|
||||
>
|
||||
<MaintenanceVisitForm
|
||||
key={editingVisit ? `edit-${editingVisit.id}` : 'create'}
|
||||
visit={editingVisit || undefined}
|
||||
customers={customers}
|
||||
vehicles={vehicles}
|
||||
maintenanceTypes={maintenanceTypes}
|
||||
onCancel={handleCloseForm}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* View Modal */}
|
||||
<Modal
|
||||
isOpen={showViewModal}
|
||||
onClose={handleCloseViewModal}
|
||||
title="تفاصيل زيارة الصيانة"
|
||||
size="xl"
|
||||
>
|
||||
{viewingVisit && (
|
||||
<MaintenanceVisitDetailsView visit={viewingVisit} />
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user