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

387
app/routes/customers.tsx Normal file
View File

@@ -0,0 +1,387 @@
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 {
getCustomers,
createCustomer,
updateCustomer,
deleteCustomer,
getCustomerById
} from "~/lib/customer-management.server";
import { validateCustomer } from "~/lib/validation";
import { DashboardLayout } from "~/components/layout/DashboardLayout";
import { CustomerList } from "~/components/customers/CustomerList";
import { CustomerForm } from "~/components/customers/CustomerForm";
import { CustomerDetailsView } from "~/components/customers/CustomerDetailsView";
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 { CustomerWithVehicles } 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 { customers, total, totalPages } = await getCustomers(searchQuery, page, limit);
return json({
customers,
total,
totalPages,
currentPage: page,
searchQuery,
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 customerData = {
name: formData.get("name") as string,
phone: formData.get("phone") as string || undefined,
email: formData.get("email") as string || undefined,
address: formData.get("address") as string || undefined,
};
// Validate customer data
const validation = validateCustomer(customerData);
if (!validation.isValid) {
return json({
success: false,
errors: validation.errors,
action: "create"
}, { status: 400 });
}
const result = await createCustomer(customerData);
if (result.success) {
return json({
success: true,
customer: result.customer,
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 customerData = {
name: formData.get("name") as string,
phone: formData.get("phone") as string || undefined,
email: formData.get("email") as string || undefined,
address: formData.get("address") as string || undefined,
};
// Validate customer data
const validation = validateCustomer(customerData);
if (!validation.isValid) {
return json({
success: false,
errors: validation.errors,
action: "update"
}, { status: 400 });
}
const result = await updateCustomer(id, customerData);
if (result.success) {
return json({
success: true,
customer: result.customer,
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 deleteCustomer(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 customer = await getCustomerById(id);
if (customer) {
return json({
success: true,
customer,
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 CustomersPage() {
const { customers, total, totalPages, currentPage, searchQuery, 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 [selectedCustomer, setSelectedCustomer] = useState<CustomerWithVehicles | null>(null);
const [searchValue, setSearchValue] = useState(searchQuery);
const isLoading = navigation.state !== "idle";
// Debounce search value to avoid too many requests
const debouncedSearchValue = useDebounce(searchValue, 300);
// Handle search automatically when debounced value changes
useEffect(() => {
if (debouncedSearchValue !== searchQuery) {
const newSearchParams = new URLSearchParams(searchParams);
if (debouncedSearchValue) {
newSearchParams.set("search", debouncedSearchValue);
} else {
newSearchParams.delete("search");
}
newSearchParams.set("page", "1"); // Reset to first page
setSearchParams(newSearchParams);
}
}, [debouncedSearchValue, searchQuery, searchParams, setSearchParams]);
// Clear search function
const clearSearch = () => {
setSearchValue("");
};
// Handle pagination
const handlePageChange = (page: number) => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set("page", page.toString());
setSearchParams(newSearchParams);
};
// Handle create customer
const handleCreateCustomer = () => {
setSelectedCustomer(null);
setShowCreateModal(true);
};
// Handle view customer
const handleViewCustomer = (customer: CustomerWithVehicles) => {
setSelectedCustomer(customer);
setShowViewModal(true);
};
// Handle edit customer
const handleEditCustomer = (customer: CustomerWithVehicles) => {
setSelectedCustomer(customer);
setShowEditModal(true);
};
// 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>
<p className="text-gray-600 mt-1">
إجمالي العملاء: {total}
</p>
</div>
<Button
onClick={handleCreateCustomer}
disabled={isLoading}
className="bg-blue-600 hover:bg-blue-700"
>
إضافة عميل جديد
</Button>
</Flex>
{/* Search */}
<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 && (
<div className="pointer-events-auto">
<button
onClick={clearSearch}
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>
{(searchQuery || debouncedSearchValue !== searchQuery) && (
<div className="flex items-center text-sm text-gray-500">
{debouncedSearchValue !== searchQuery && (
<span className="flex items-center">
<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>
جاري البحث...
</span>
)}
</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>
)}
{/* Customer List */}
<CustomerList
customers={customers}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
onViewCustomer={handleViewCustomer}
onEditCustomer={handleEditCustomer}
isLoading={isLoading}
actionData={actionData}
/>
{/* Create Customer Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="إضافة عميل جديد"
>
<CustomerForm
onCancel={() => setShowCreateModal(false)}
errors={actionData?.action === "create" ? actionData.errors : undefined}
isLoading={isLoading}
/>
</Modal>
{/* View Customer Modal */}
<Modal
isOpen={showViewModal}
onClose={() => setShowViewModal(false)}
title={selectedCustomer ? `تفاصيل العميل - ${selectedCustomer.name}` : "تفاصيل العميل"}
size="xl"
>
{selectedCustomer && (
<CustomerDetailsView
customer={selectedCustomer}
onEdit={() => {
setShowViewModal(false);
handleEditCustomer(selectedCustomer);
}}
onClose={() => setShowViewModal(false)}
/>
)}
</Modal>
{/* Edit Customer Modal */}
<Modal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
title="تعديل العميل"
>
{selectedCustomer && (
<CustomerForm
customer={selectedCustomer}
onCancel={() => setShowEditModal(false)}
errors={actionData?.action === "update" ? actionData.errors : undefined}
isLoading={isLoading}
/>
)}
</Modal>
</div>
</DashboardLayout>
);
}