car_mms/app/routes/maintenance-visits.tsx
2025-09-11 14:22:27 +03:00

552 lines
21 KiB
TypeScript
Raw 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 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>
);
}