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