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

View File

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