497 lines
18 KiB
TypeScript
497 lines
18 KiB
TypeScript
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>
|
||
);
|
||
} |