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

497 lines
18 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 type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData, useActionData, useNavigation, useSearchParams } from "@remix-run/react";
import { useState, useEffect } from "react";
import { requireUser } from "~/lib/auth.server";
import { useDebounce } from "~/hooks/useDebounce";
import {
getVehicles,
createVehicle,
updateVehicle,
deleteVehicle,
getVehicleById
} from "~/lib/vehicle-management.server";
import { getCustomersForSelect } from "~/lib/customer-management.server";
import { validateVehicle } from "~/lib/validation";
import { DashboardLayout } from "~/components/layout/DashboardLayout";
import { VehicleList } from "~/components/vehicles/VehicleList";
import { VehicleForm } from "~/components/vehicles/VehicleForm";
import { VehicleDetailsView } from "~/components/vehicles/VehicleDetailsView";
import { Modal } from "~/components/ui/Modal";
import { Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input";
import { Flex } from "~/components/layout/Flex";
import type { VehicleWithOwner, VehicleWithRelations } from "~/types/database";
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireUser(request);
const url = new URL(request.url);
const searchQuery = url.searchParams.get("search") || "";
const page = parseInt(url.searchParams.get("page") || "1");
const limit = parseInt(url.searchParams.get("limit") || "10");
const ownerId = url.searchParams.get("ownerId") ? parseInt(url.searchParams.get("ownerId")!) : undefined;
const customerId = url.searchParams.get("customerId") ? parseInt(url.searchParams.get("customerId")!) : undefined;
const plateNumber = url.searchParams.get("plateNumber") || undefined;
const [vehiclesResult, customers] = await Promise.all([
getVehicles(searchQuery, page, limit, customerId || ownerId, plateNumber),
getCustomersForSelect(),
]);
return json({
vehicles: vehiclesResult.vehicles,
total: vehiclesResult.total,
totalPages: vehiclesResult.totalPages,
currentPage: page,
searchQuery,
ownerId: customerId || ownerId,
customerId,
plateNumber,
customers,
user,
});
}
export async function action({ request }: ActionFunctionArgs) {
const user = await requireUser(request);
const formData = await request.formData();
const action = formData.get("_action") as string;
switch (action) {
case "create": {
const vehicleData = {
plateNumber: formData.get("plateNumber") as string,
bodyType: formData.get("bodyType") as string,
manufacturer: formData.get("manufacturer") as string,
model: formData.get("model") as string,
trim: formData.get("trim") as string || undefined,
year: parseInt(formData.get("year") as string),
transmission: formData.get("transmission") as string,
fuel: formData.get("fuel") as string,
cylinders: formData.get("cylinders") ? parseInt(formData.get("cylinders") as string) : undefined,
engineDisplacement: formData.get("engineDisplacement") ? parseFloat(formData.get("engineDisplacement") as string) : undefined,
useType: formData.get("useType") as string,
ownerId: parseInt(formData.get("ownerId") as string),
};
// Validate vehicle data
const validation = validateVehicle(vehicleData);
if (!validation.isValid) {
return json({
success: false,
errors: validation.errors,
action: "create"
}, { status: 400 });
}
const result = await createVehicle(vehicleData);
if (result.success) {
return json({
success: true,
vehicle: result.vehicle,
action: "create",
message: "تم إنشاء المركبة بنجاح"
});
} else {
return json({
success: false,
error: result.error,
action: "create"
}, { status: 400 });
}
}
case "update": {
const id = parseInt(formData.get("id") as string);
const vehicleData = {
plateNumber: formData.get("plateNumber") as string,
bodyType: formData.get("bodyType") as string,
manufacturer: formData.get("manufacturer") as string,
model: formData.get("model") as string,
trim: formData.get("trim") as string || undefined,
year: parseInt(formData.get("year") as string),
transmission: formData.get("transmission") as string,
fuel: formData.get("fuel") as string,
cylinders: formData.get("cylinders") ? parseInt(formData.get("cylinders") as string) : undefined,
engineDisplacement: formData.get("engineDisplacement") ? parseFloat(formData.get("engineDisplacement") as string) : undefined,
useType: formData.get("useType") as string,
ownerId: parseInt(formData.get("ownerId") as string),
};
// Validate vehicle data
const validation = validateVehicle(vehicleData);
if (!validation.isValid) {
return json({
success: false,
errors: validation.errors,
action: "update"
}, { status: 400 });
}
const result = await updateVehicle(id, vehicleData);
if (result.success) {
return json({
success: true,
vehicle: result.vehicle,
action: "update",
message: "تم تحديث المركبة بنجاح"
});
} else {
return json({
success: false,
error: result.error,
action: "update"
}, { status: 400 });
}
}
case "delete": {
const id = parseInt(formData.get("id") as string);
const result = await deleteVehicle(id);
if (result.success) {
return json({
success: true,
action: "delete",
message: "تم حذف المركبة بنجاح"
});
} else {
return json({
success: false,
error: result.error,
action: "delete"
}, { status: 400 });
}
}
case "get": {
const id = parseInt(formData.get("id") as string);
const vehicle = await getVehicleById(id);
if (vehicle) {
return json({
success: true,
vehicle,
action: "get"
});
} else {
return json({
success: false,
error: "المركبة غير موجودة",
action: "get"
}, { status: 404 });
}
}
default:
return json({
success: false,
error: "إجراء غير صحيح",
action: "unknown"
}, { status: 400 });
}
}
export default function VehiclesPage() {
const { vehicles, total, totalPages, currentPage, searchQuery, ownerId, customerId, plateNumber, customers, user } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const [searchParams, setSearchParams] = useSearchParams();
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showViewModal, setShowViewModal] = useState(false);
const [selectedVehicle, setSelectedVehicle] = useState<VehicleWithOwner | VehicleWithRelations | null>(null);
const [searchValue, setSearchValue] = useState(searchQuery);
const [selectedOwnerId, setSelectedOwnerId] = useState(ownerId?.toString() || "");
const [isLoadingVehicleDetails, setIsLoadingVehicleDetails] = useState(false);
const isLoading = navigation.state !== "idle";
// Debounce search value to avoid too many requests
const debouncedSearchValue = useDebounce(searchValue, 300);
const debouncedOwnerId = useDebounce(selectedOwnerId, 300);
// Handle search automatically when debounced values change
useEffect(() => {
if (debouncedSearchValue !== searchQuery || debouncedOwnerId !== (ownerId?.toString() || "")) {
const newSearchParams = new URLSearchParams(searchParams);
if (debouncedSearchValue) {
newSearchParams.set("search", debouncedSearchValue);
} else {
newSearchParams.delete("search");
}
if (debouncedOwnerId) {
newSearchParams.set("ownerId", debouncedOwnerId);
} else {
newSearchParams.delete("ownerId");
}
newSearchParams.set("page", "1"); // Reset to first page
setSearchParams(newSearchParams);
}
}, [debouncedSearchValue, debouncedOwnerId, searchQuery, ownerId, searchParams, setSearchParams]);
// Clear search function
const clearSearch = () => {
setSearchValue("");
setSelectedOwnerId("");
};
// Handle pagination
const handlePageChange = (page: number) => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set("page", page.toString());
setSearchParams(newSearchParams);
};
// Handle create vehicle
const handleCreateVehicle = () => {
setSelectedVehicle(null);
setShowCreateModal(true);
};
// Handle edit vehicle
const handleEditVehicle = (vehicle: VehicleWithOwner | VehicleWithRelations) => {
setSelectedVehicle(vehicle);
setShowEditModal(true);
};
// Handle view vehicle
const handleViewVehicle = async (vehicle: VehicleWithOwner) => {
// First show the modal with basic data
setSelectedVehicle(vehicle);
setShowViewModal(true);
setIsLoadingVehicleDetails(true);
// Then fetch full vehicle details with maintenance visits in the background
try {
const form = new FormData();
form.append("_action", "get");
form.append("id", vehicle.id.toString());
const response = await fetch(window.location.pathname, {
method: "POST",
body: form,
});
if (response.ok) {
const result = await response.json();
if (result.success && result.vehicle) {
setSelectedVehicle(result.vehicle);
}
}
} catch (error) {
console.error("Failed to fetch full vehicle details:", error);
// Keep the basic vehicle data if fetch fails
} finally {
setIsLoadingVehicleDetails(false);
}
};
// Close modals on successful action
useEffect(() => {
if (actionData?.success && actionData.action === "create") {
setShowCreateModal(false);
}
if (actionData?.success && actionData.action === "update") {
setShowEditModal(false);
}
}, [actionData]);
return (
<DashboardLayout user={user}>
<div className="space-y-6">
{/* Header */}
<Flex justify="between" align="center" className="flex-wrap gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">إدارة المركبات</h1>
<div className="flex items-center gap-4 mt-1">
<p className="text-gray-600">
إجمالي المركبات: {total}
</p>
{customerId && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded-full">
مفلترة حسب العميل
</span>
)}
{plateNumber && (
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded-full">
مفلترة حسب رقم اللوحة: {plateNumber}
</span>
)}
</div>
</div>
<Button
onClick={handleCreateVehicle}
disabled={isLoading}
className="bg-blue-600 hover:bg-blue-700"
>
إضافة مركبة جديدة
</Button>
</Flex>
{/* Search and Filters */}
<div className="bg-white p-4 rounded-lg shadow-sm border">
<Flex gap="md" 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={selectedOwnerId}
onChange={(e) => setSelectedOwnerId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">جميع المالكين</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
</div>
{(searchQuery || ownerId || debouncedSearchValue !== searchQuery || debouncedOwnerId !== (ownerId?.toString() || "")) && (
<div className="flex items-center gap-2">
{(debouncedSearchValue !== searchQuery || debouncedOwnerId !== (ownerId?.toString() || "")) && (
<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 || ownerId) && (
<Button
onClick={clearSearch}
disabled={isLoading}
variant="outline"
size="sm"
>
مسح البحث
</Button>
)}
</div>
)}
</Flex>
</div>
{/* Action Messages */}
{actionData?.success && actionData.message && (
<div className="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg">
{actionData.message}
</div>
)}
{actionData?.error && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg">
{actionData.error}
</div>
)}
{/* Vehicle List */}
<VehicleList
vehicles={vehicles}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
onEditVehicle={handleEditVehicle}
onViewVehicle={handleViewVehicle}
isLoading={isLoading}
actionData={actionData}
/>
{/* Create Vehicle Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="إضافة مركبة جديدة"
size="lg"
>
<VehicleForm
customers={customers}
onCancel={() => setShowCreateModal(false)}
errors={actionData?.action === "create" ? actionData.errors : undefined}
isLoading={isLoading}
/>
</Modal>
{/* Edit Vehicle Modal */}
<Modal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
title="تعديل المركبة"
size="lg"
>
{selectedVehicle && (
<VehicleForm
vehicle={selectedVehicle}
customers={customers}
onCancel={() => setShowEditModal(false)}
errors={actionData?.action === "update" ? actionData.errors : undefined}
isLoading={isLoading}
/>
)}
</Modal>
{/* View Vehicle Modal */}
<Modal
isOpen={showViewModal}
onClose={() => {
setShowViewModal(false);
setIsLoadingVehicleDetails(false);
}}
title={selectedVehicle ? `تفاصيل المركبة - ${selectedVehicle.plateNumber}` : "تفاصيل المركبة"}
size="xl"
>
{selectedVehicle && (
<VehicleDetailsView
vehicle={selectedVehicle}
onEdit={() => {
setShowViewModal(false);
handleEditVehicle(selectedVehicle);
}}
onClose={() => {
setShowViewModal(false);
setIsLoadingVehicleDetails(false);
}}
isLoadingVisits={isLoadingVehicleDetails}
/>
)}
</Modal>
</div>
</DashboardLayout>
);
}