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

22
app/routes/_index.tsx Normal file
View File

@@ -0,0 +1,22 @@
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { getUserId } from "~/lib/auth.server";
export const meta: MetaFunction = () => {
return [
{ title: "نظام إدارة صيانة السيارات" },
{ name: "description", content: "نظام شامل لإدارة صيانة السيارات" },
];
};
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await getUserId(request);
if (userId) {
// User is authenticated, redirect to dashboard
return redirect("/dashboard");
} else {
// User is not authenticated, redirect to signin
return redirect("/signin");
}
}

View File

@@ -0,0 +1,66 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { requireAuthLevel } from "~/lib/auth-helpers.server";
import { AUTH_LEVELS } from "~/types/auth";
export async function loader({ request }: LoaderFunctionArgs) {
// Only superadmins can access this route
const user = await requireAuthLevel(request, AUTH_LEVELS.SUPERADMIN);
return json({ user });
}
export async function action({ request }: ActionFunctionArgs) {
// Only superadmins can perform this action
await requireAuthLevel(request, AUTH_LEVELS.SUPERADMIN);
const formData = await request.formData();
const action = formData.get("action");
if (action === "enable_signup") {
// Redirect to signup with a special parameter that bypasses the check
return redirect("/signup?admin_override=true");
}
return redirect("/admin/enable-signup");
}
export default function EnableSignup() {
const { user } = useLoaderData<typeof loader>();
return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8" dir="rtl">
<div className="max-w-md mx-auto">
<div className="bg-white shadow rounded-lg p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
تفعيل التسجيل للمسؤولين
</h2>
<p className="text-sm text-gray-600 mb-6">
مرحباً {user.name}، يمكنك تفعيل صفحة التسجيل مؤقتاً لإنشاء حسابات جديدة.
</p>
<Form method="post">
<button
type="submit"
name="action"
value="enable_signup"
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-md"
>
الانتقال إلى صفحة التسجيل
</button>
</Form>
<div className="mt-4">
<a
href="/dashboard"
className="text-sm text-blue-600 hover:text-blue-500"
>
العودة إلى لوحة التحكم
</a>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { getManufacturers, getModelsByManufacturer, getBodyType } from "~/lib/car-dataset-management.server";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const action = url.searchParams.get("action");
const manufacturer = url.searchParams.get("manufacturer");
const model = url.searchParams.get("model");
try {
switch (action) {
case "manufacturers": {
const manufacturers = await getManufacturers();
return json({ success: true, data: manufacturers });
}
case "models": {
if (!manufacturer) {
return json({ success: false, error: "Manufacturer is required" }, { status: 400 });
}
const models = await getModelsByManufacturer(manufacturer);
return json({ success: true, data: models });
}
case "bodyType": {
if (!manufacturer || !model) {
return json({ success: false, error: "Manufacturer and model are required" }, { status: 400 });
}
const bodyType = await getBodyType(manufacturer, model);
return json({ success: true, data: bodyType });
}
default:
return json({ success: false, error: "Invalid action" }, { status: 400 });
}
} catch (error) {
console.error("Car dataset API error:", error);
return json(
{ success: false, error: "Internal server error" },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,20 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { requireUser } from "~/lib/auth.server";
import { searchCustomers } from "~/lib/customer-management.server";
export async function loader({ request }: LoaderFunctionArgs) {
await requireUser(request);
const url = new URL(request.url);
const query = url.searchParams.get("q") || "";
const limit = parseInt(url.searchParams.get("limit") || "10");
if (!query || query.trim().length < 2) {
return json({ customers: [] });
}
const customers = await searchCustomers(query, limit);
return json({ customers });
}

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>
);
}

233
app/routes/dashboard.tsx Normal file
View File

@@ -0,0 +1,233 @@
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { requireAuthentication } from "~/lib/auth-middleware.server";
import { getFinancialSummary } from "~/lib/financial-reporting.server";
import { prisma } from "~/lib/db.server";
import { DashboardLayout } from "~/components/layout/DashboardLayout";
import { useSettings } from "~/contexts/SettingsContext";
export const meta: MetaFunction = () => {
return [
{ title: "لوحة التحكم - نظام إدارة صيانة السيارات" },
{ name: "description", content: "لوحة التحكم الرئيسية لنظام إدارة صيانة السيارات" },
];
};
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireAuthentication(request);
// Get dashboard statistics
const [
customersCount,
vehiclesCount,
maintenanceVisitsCount,
financialSummary
] = await Promise.all([
prisma.customer.count(),
prisma.vehicle.count(),
prisma.maintenanceVisit.count(),
user.authLevel <= 2 ? getFinancialSummary() : null, // Only for admin and above
]);
return json({
user,
stats: {
customersCount,
vehiclesCount,
maintenanceVisitsCount,
financialSummary,
}
});
}
export default function Dashboard() {
const { formatCurrency } = useSettings();
const { user, stats } = useLoaderData<typeof loader>();
return (
<DashboardLayout user={user}>
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">لوحة التحكم</h1>
<p className="text-gray-600">مرحباً بك، {user.name}</p>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Customers Card */}
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600">العملاء</p>
<p className="text-2xl font-bold text-blue-600">
{stats.customersCount}
</p>
<p className="text-sm text-gray-500">إجمالي العملاء المسجلين</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
</div>
</div>
</div>
{/* Vehicles Card */}
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600">المركبات</p>
<p className="text-2xl font-bold text-green-600">
{stats.vehiclesCount}
</p>
<p className="text-sm text-gray-500">إجمالي المركبات المسجلة</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6 0a1 1 0 001 1h4a1 1 0 001-1m-6 0V9a1 1 0 00-1-1v0a1 1 0 00-1 1v8a1 1 0 001 1z" />
</svg>
</div>
</div>
</div>
{/* Maintenance Visits Card */}
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600">زيارات الصيانة</p>
<p className="text-2xl font-bold text-purple-600">
{stats.maintenanceVisitsCount}
</p>
<p className="text-sm text-gray-500">إجمالي زيارات الصيانة</p>
</div>
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
</div>
</div>
{/* Financial Summary Card (Admin only) */}
{stats.financialSummary && (
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600">صافي الربح</p>
<p className={`text-2xl font-bold ${stats.financialSummary.netProfit >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatCurrency(stats.financialSummary.netProfit)}
</p>
<p className="text-sm text-gray-500">
هامش الربح: {stats.financialSummary.profitMargin.toFixed(1)}%
</p>
</div>
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
stats.financialSummary.netProfit >= 0 ? 'bg-green-100' : 'bg-red-100'
}`}>
<svg className={`w-6 h-6 ${stats.financialSummary.netProfit >= 0 ? 'text-green-600' : 'text-red-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
</div>
</div>
)}
</div>
{/* Financial Summary Details (Admin only) */}
{stats.financialSummary && (
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold text-gray-900">الملخص المالي</h2>
<a
href="/financial-reports"
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
عرض التقارير المفصلة
</a>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<p className="text-sm text-gray-600">إجمالي الإيرادات</p>
<p className="text-xl font-bold text-green-600">
{formatCurrency(stats.financialSummary.totalIncome)}
</p>
<p className="text-xs text-gray-500">
{stats.financialSummary.incomeCount} عملية
</p>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">إجمالي المصروفات</p>
<p className="text-xl font-bold text-red-600">
{formatCurrency(stats.financialSummary.totalExpenses)}
</p>
<p className="text-xs text-gray-500">
{stats.financialSummary.expenseCount} مصروف
</p>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">صافي الربح</p>
<p className={`text-xl font-bold ${stats.financialSummary.netProfit >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatCurrency(stats.financialSummary.netProfit)}
</p>
<p className="text-xs text-gray-500">
{stats.financialSummary.profitMargin.toFixed(1)}% هامش ربح
</p>
</div>
</div>
</div>
)}
{/* Quick Actions */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold text-gray-900 mb-4">الإجراءات السريعة</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<a
href="/customers"
className="flex items-center p-3 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors"
>
<svg className="w-5 h-5 text-blue-600 ml-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span className="text-sm font-medium text-blue-900">إضافة عميل جديد</span>
</a>
<a
href="/vehicles"
className="flex items-center p-3 bg-green-50 rounded-lg hover:bg-green-100 transition-colors"
>
<svg className="w-5 h-5 text-green-600 ml-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span className="text-sm font-medium text-green-900">تسجيل مركبة جديدة</span>
</a>
<a
href="/maintenance-visits"
className="flex items-center p-3 bg-purple-50 rounded-lg hover:bg-purple-100 transition-colors"
>
<svg className="w-5 h-5 text-purple-600 ml-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span className="text-sm font-medium text-purple-900">إضافة زيارة صيانة</span>
</a>
{user.authLevel <= 2 && (
<a
href="/expenses"
className="flex items-center p-3 bg-orange-50 rounded-lg hover:bg-orange-100 transition-colors"
>
<svg className="w-5 h-5 text-orange-600 ml-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
<span className="text-sm font-medium text-orange-900">إضافة مصروف</span>
</a>
)}
</div>
</div>
</div>
</DashboardLayout>
);
}

498
app/routes/expenses.tsx Normal file
View File

@@ -0,0 +1,498 @@
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { useLoaderData, useActionData, useNavigation, useSearchParams } from "@remix-run/react";
import { useState, useEffect } from "react";
import { requireAuth } from "~/lib/auth-middleware.server";
import { useDebounce } from "~/hooks/useDebounce";
import {
getExpenses,
createExpense,
updateExpense,
deleteExpense,
getExpenseById,
getExpenseCategories
} from "~/lib/expense-management.server";
import { validateExpense } from "~/lib/validation";
import { DashboardLayout } from "~/components/layout/DashboardLayout";
import { ExpenseForm } from "~/components/expenses/ExpenseForm";
import { Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input";
import { Modal } from "~/components/ui/Modal";
import { DataTable } from "~/components/ui/DataTable";
import { Flex } from "~/components/layout/Flex";
import { useSettings } from "~/contexts/SettingsContext";
import { EXPENSE_CATEGORIES, PAGINATION } from "~/lib/constants";
import type { Expense } from "@prisma/client";
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireAuth(request, 2); // Admin level required
const url = new URL(request.url);
const searchQuery = url.searchParams.get("search") || "";
const page = parseInt(url.searchParams.get("page") || "1");
const category = url.searchParams.get("category") || "";
const dateFrom = url.searchParams.get("dateFrom")
? new Date(url.searchParams.get("dateFrom")!)
: undefined;
const dateTo = url.searchParams.get("dateTo")
? new Date(url.searchParams.get("dateTo")!)
: undefined;
const { expenses, total, totalPages } = await getExpenses(
searchQuery,
page,
PAGINATION.DEFAULT_PAGE_SIZE,
category,
dateFrom,
dateTo
);
const categories = await getExpenseCategories();
return json({
user,
expenses,
total,
totalPages,
currentPage: page,
searchQuery,
category,
dateFrom: dateFrom?.toISOString().split('T')[0] || "",
dateTo: dateTo?.toISOString().split('T')[0] || "",
categories,
});
}
export async function action({ request }: ActionFunctionArgs) {
await requireAuth(request, 2); // Admin level required
const formData = await request.formData();
const action = formData.get("_action") as string;
switch (action) {
case "create": {
const expenseData = {
description: formData.get("description") as string,
category: formData.get("category") as string,
amount: parseFloat(formData.get("amount") as string),
expenseDate: formData.get("expenseDate") as string,
};
const validation = validateExpense({
description: expenseData.description,
category: expenseData.category,
amount: expenseData.amount,
});
if (!validation.isValid) {
return json({
success: false,
errors: validation.errors,
action: "create"
}, { status: 400 });
}
try {
const expense = await createExpense({
description: expenseData.description,
category: expenseData.category,
amount: expenseData.amount,
expenseDate: expenseData.expenseDate ? new Date(expenseData.expenseDate) : undefined,
});
return json({
success: true,
expense,
action: "create",
message: "تم إنشاء المصروف بنجاح"
});
} catch (error) {
return json({
success: false,
error: "حدث خطأ أثناء إضافة المصروف",
action: "create"
}, { status: 500 });
}
}
case "update": {
const id = parseInt(formData.get("id") as string);
const expenseData = {
description: formData.get("description") as string,
category: formData.get("category") as string,
amount: parseFloat(formData.get("amount") as string),
expenseDate: formData.get("expenseDate") as string,
};
const validation = validateExpense({
description: expenseData.description,
category: expenseData.category,
amount: expenseData.amount,
});
if (!validation.isValid) {
return json({
success: false,
errors: validation.errors,
action: "update"
}, { status: 400 });
}
try {
const expense = await updateExpense(id, {
description: expenseData.description,
category: expenseData.category,
amount: expenseData.amount,
expenseDate: expenseData.expenseDate ? new Date(expenseData.expenseDate) : undefined,
});
return json({
success: true,
expense,
action: "update",
message: "تم تحديث المصروف بنجاح"
});
} catch (error) {
return json({
success: false,
error: "حدث خطأ أثناء تحديث المصروف",
action: "update"
}, { status: 500 });
}
}
case "delete": {
const id = parseInt(formData.get("id") as string);
try {
await deleteExpense(id);
return json({
success: true,
action: "delete",
message: "تم حذف المصروف بنجاح"
});
} catch (error) {
return json({
success: false,
error: "حدث خطأ أثناء حذف المصروف",
action: "delete"
}, { status: 500 });
}
}
case "get": {
const id = parseInt(formData.get("id") as string);
const expense = await getExpenseById(id);
if (expense) {
return json({
success: true,
expense,
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 ExpensesPage() {
const { formatCurrency, formatDate } = useSettings();
const {
user,
expenses,
total,
totalPages,
currentPage,
searchQuery,
category,
dateFrom,
dateTo,
categories
} = 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 [selectedExpense, setSelectedExpense] = useState<Expense | 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("");
};
const handleFilter = (filterType: string, value: string) => {
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set(filterType, value);
} else {
newParams.delete(filterType);
}
newParams.set("page", "1");
setSearchParams(newParams);
};
// Handle pagination
const handlePageChange = (page: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set("page", page.toString());
setSearchParams(newParams);
};
// Handle create expense
const handleCreateExpense = () => {
setSelectedExpense(null);
setShowCreateModal(true);
};
// Handle edit expense
const handleEditExpense = (expense: Expense) => {
setSelectedExpense(expense);
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]);
const columns = [
{
key: "description",
header: "الوصف",
render: (expense: Expense) => expense.description,
},
{
key: "category",
header: "الفئة",
render: (expense: Expense) => {
const categoryLabel = EXPENSE_CATEGORIES.find(c => c.value === expense.category)?.label;
return categoryLabel || expense.category;
},
},
{
key: "amount",
header: "المبلغ",
render: (expense: Expense) => formatCurrency(expense.amount),
},
{
key: "expenseDate",
header: "تاريخ المصروف",
render: (expense: Expense) => formatDate(expense.expenseDate),
},
{
key: "createdDate",
header: "تاريخ الإضافة",
render: (expense: Expense) => formatDate(expense.createdDate),
},
{
key: "actions",
header: "الإجراءات",
render: (expense: Expense) => (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditExpense(expense)}
disabled={isLoading}
>
تعديل
</Button>
</div>
),
},
];
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={handleCreateExpense}
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>
{/* Filters */}
<div className="bg-white p-4 rounded-lg shadow-sm border">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<select
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
value={category}
onChange={(e) => handleFilter("category", e.target.value)}
>
<option value="">جميع الفئات</option>
{EXPENSE_CATEGORIES.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
<Input
type="date"
placeholder="من تاريخ"
value={dateFrom}
onChange={(e) => handleFilter("dateFrom", e.target.value)}
/>
<Input
type="date"
placeholder="إلى تاريخ"
value={dateTo}
onChange={(e) => handleFilter("dateTo", e.target.value)}
/>
</div>
</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>
)}
{/* Expenses Table */}
<DataTable
data={expenses}
columns={columns}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
{/* Create Expense Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="إضافة مصروف جديد"
>
<ExpenseForm
onCancel={() => setShowCreateModal(false)}
errors={actionData?.action === "create" ? actionData.errors : undefined}
isLoading={isLoading}
/>
</Modal>
{/* Edit Expense Modal */}
<Modal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
title="تعديل المصروف"
>
{selectedExpense && (
<ExpenseForm
expense={selectedExpense}
onCancel={() => setShowEditModal(false)}
errors={actionData?.action === "update" ? actionData.errors : undefined}
isLoading={isLoading}
/>
)}
</Modal>
</div>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,396 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData, useSearchParams } from "@remix-run/react";
import { requireAuth } from "~/lib/auth-middleware.server";
import {
getFinancialSummary,
getMonthlyFinancialData,
getIncomeByMaintenanceType,
getExpenseBreakdown,
getTopCustomersByRevenue,
getFinancialTrends
} from "~/lib/financial-reporting.server";
import { DashboardLayout } from "~/components/layout/DashboardLayout";
import { Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input";
import { useSettings } from "~/contexts/SettingsContext";
// Arabic Gregorian month names
const ARABIC_GREGORIAN_MONTHS: Record<string, string> = {
"1": "كانون الثاني",
"2": "شباط",
"3": "آذار",
"4": "نيسان",
"5": "أيار",
"6": "حزيران",
"7": "تموز",
"8": "آب",
"9": "أيلول",
"10": "تشرين الأول",
"11": "تشرين الثاني",
"12": "كانون الأول",
};
function getArabicMonthName(monthNumber: string): string {
return `${ARABIC_GREGORIAN_MONTHS[monthNumber]} (${monthNumber})`;
}
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireAuth(request, 2); // Admin level required
const url = new URL(request.url);
const dateFrom = url.searchParams.get("dateFrom")
? new Date(url.searchParams.get("dateFrom")!)
: undefined;
const dateTo = url.searchParams.get("dateTo")
? new Date(url.searchParams.get("dateTo")!)
: undefined;
// Get all financial data
const [
financialSummary,
monthlyData,
incomeByType,
expenseBreakdown,
topCustomers,
trends
] = await Promise.all([
getFinancialSummary(dateFrom, dateTo),
getMonthlyFinancialData(),
getIncomeByMaintenanceType(dateFrom, dateTo),
getExpenseBreakdown(dateFrom, dateTo),
getTopCustomersByRevenue(10, dateFrom, dateTo),
dateFrom && dateTo ? getFinancialTrends(dateFrom, dateTo) : null,
]);
return json({
user,
financialSummary,
monthlyData,
incomeByType,
expenseBreakdown,
topCustomers,
trends,
dateFrom: dateFrom?.toISOString().split('T')[0] || "",
dateTo: dateTo?.toISOString().split('T')[0] || "",
});
}
export default function FinancialReportsPage() {
const { formatCurrency } = useSettings();
const {
user,
financialSummary,
monthlyData,
incomeByType,
expenseBreakdown,
topCustomers,
trends,
dateFrom,
dateTo
} = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const handleDateFilter = (type: string, value: string) => {
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set(type, value);
} else {
newParams.delete(type);
}
setSearchParams(newParams);
};
const clearFilters = () => {
setSearchParams({});
};
return (
<DashboardLayout user={user}>
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">التقارير المالية</h1>
<p className="text-gray-600">تحليل شامل للوضع المالي للمؤسسة</p>
</div>
</div>
{/* Date Filters */}
<div className="bg-white p-4 rounded-lg shadow">
<div className="flex flex-wrap gap-4 items-end">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
من تاريخ
</label>
<Input
type="date"
value={dateFrom}
onChange={(e) => handleDateFilter("dateFrom", e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
إلى تاريخ
</label>
<Input
type="date"
value={dateTo}
onChange={(e) => handleDateFilter("dateTo", e.target.value)}
/>
</div>
<Button
variant="outline"
onClick={clearFilters}
>
مسح الفلاتر
</Button>
</div>
</div>
{/* Financial Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600">إجمالي الإيرادات</p>
<p className="text-2xl font-bold text-green-600">
{formatCurrency(financialSummary.totalIncome)}
</p>
<p className="text-sm text-gray-500">
{financialSummary.incomeCount} عملية
</p>
</div>
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1" />
</svg>
</div>
</div>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600">إجمالي المصروفات</p>
<p className="text-2xl font-bold text-red-600">
{formatCurrency(financialSummary.totalExpenses)}
</p>
<p className="text-sm text-gray-500">
{financialSummary.expenseCount} مصروف
</p>
</div>
<div className="w-12 h-12 bg-red-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
</div>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600">صافي الربح</p>
<p className={`text-2xl font-bold ${financialSummary.netProfit >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatCurrency(financialSummary.netProfit)}
</p>
<p className="text-sm text-gray-500">
هامش الربح: {financialSummary.profitMargin.toFixed(1)}%
</p>
</div>
<div className={`w-12 h-12 rounded-lg flex items-center justify-center ${
financialSummary.netProfit >= 0 ? 'bg-green-100' : 'bg-red-100'
}`}>
<svg className={`w-6 h-6 ${financialSummary.netProfit >= 0 ? 'text-green-600' : 'text-red-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
</div>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<div className="flex items-center">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600">متوسط الإيراد الشهري</p>
<p className="text-2xl font-bold text-blue-600">
{formatCurrency(monthlyData.reduce((sum, month) => sum + month.income, 0) / Math.max(monthlyData.length, 1))}
</p>
<p className="text-sm text-gray-500">
آخر 12 شهر
</p>
</div>
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
</div>
</div>
</div>
</div>
{/* Trends (if date range is selected) */}
{trends && (
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold text-gray-900 mb-4">مقارنة الفترات</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="text-center">
<p className="text-sm text-gray-600">نمو الإيرادات</p>
<p className={`text-2xl font-bold ${trends.trends.incomeGrowth >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{trends.trends.incomeGrowth >= 0 ? '+' : ''}{trends.trends.incomeGrowth.toFixed(1)}%
</p>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">نمو المصروفات</p>
<p className={`text-2xl font-bold ${trends.trends.expenseGrowth <= 0 ? 'text-green-600' : 'text-red-600'}`}>
{trends.trends.expenseGrowth >= 0 ? '+' : ''}{trends.trends.expenseGrowth.toFixed(1)}%
</p>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">نمو الأرباح</p>
<p className={`text-2xl font-bold ${trends.trends.profitGrowth >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{trends.trends.profitGrowth >= 0 ? '+' : ''}{trends.trends.profitGrowth.toFixed(1)}%
</p>
</div>
</div>
</div>
)}
{/* Charts and Breakdowns */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Income by Maintenance Type */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold text-gray-900 mb-4">الإيرادات حسب نوع الصيانة</h2>
<div className="space-y-3">
{incomeByType.map((item, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex-1">
<div className="flex justify-between items-center mb-1">
<span className="text-sm font-medium text-gray-700">
{item.category}
</span>
<span className="text-sm text-gray-500">
{item.percentage.toFixed(1)}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${item.percentage}%` }}
></div>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-sm font-semibold text-gray-900">
{formatCurrency(item.amount)}
</span>
<span className="text-xs text-gray-500">
{item.count} عملية
</span>
</div>
</div>
</div>
))}
</div>
</div>
{/* Expense Breakdown */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold text-gray-900 mb-4">تفصيل المصروفات</h2>
<div className="space-y-3">
{expenseBreakdown.map((item, index) => (
<div key={index} className="flex items-center justify-between">
<div className="flex-1">
<div className="flex justify-between items-center mb-1">
<span className="text-sm font-medium text-gray-700">
{item.category}
</span>
<span className="text-sm text-gray-500">
{item.percentage.toFixed(1)}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-red-600 h-2 rounded-full"
style={{ width: `${item.percentage}%` }}
></div>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-sm font-semibold text-gray-900">
{formatCurrency(item.amount)}
</span>
<span className="text-xs text-gray-500">
{item.count} مصروف
</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* Top Customers and Monthly Data */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Top Customers */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold text-gray-900 mb-4">أفضل العملاء</h2>
<div className="space-y-3">
{topCustomers.map((customer, index) => (
<div key={customer.customerId} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-3 space-x-reverse">
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
<span className="text-sm font-semibold text-blue-600">
{index + 1}
</span>
</div>
<div>
<p className="font-medium text-gray-900">{customer.customerName}</p>
<p className="text-sm text-gray-500">{customer.visitCount} زيارة</p>
</div>
</div>
<div className="text-left">
<p className="font-semibold text-gray-900">
{formatCurrency(customer.totalRevenue)}
</p>
</div>
</div>
))}
</div>
</div>
{/* Monthly Performance */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-lg font-semibold text-gray-900 mb-4">الأداء الشهري</h2>
<div className="space-y-3 max-h-96 overflow-y-auto">
{monthlyData.slice(-6).reverse().map((month, index) => (
<div key={`${month.year}-${month.month}`} className="p-3 bg-gray-50 rounded-lg">
<div className="flex justify-between items-center mb-2">
<span className="font-medium text-gray-900">
{getArabicMonthName(month.month)} {month.year}
</span>
<span className={`font-semibold ${month.profit >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatCurrency(month.profit)}
</span>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-gray-600">الإيرادات: </span>
<span className="font-medium text-green-600">
{formatCurrency(month.income)}
</span>
</div>
<div>
<span className="text-gray-600">المصروفات: </span>
<span className="font-medium text-red-600">
{formatCurrency(month.expenses)}
</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</DashboardLayout>
);
}

66
app/routes/financial.tsx Normal file
View File

@@ -0,0 +1,66 @@
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { protectFinancialRoute } from "~/lib/auth-middleware.server";
import { DashboardLayout } from "~/components/layout/DashboardLayout";
import { Text, Card, CardHeader, CardBody } from "~/components/ui";
export const meta: MetaFunction = () => {
return [
{ title: "الإدارة المالية - نظام إدارة صيانة السيارات" },
{ name: "description", content: "إدارة الأمور المالية والمصروفات" },
];
};
export async function loader({ request }: LoaderFunctionArgs) {
const user = await protectFinancialRoute(request);
return json({ user });
}
export default function Financial() {
const { user } = useLoaderData<typeof loader>();
return (
<DashboardLayout user={user}>
<div className="space-y-6">
<div>
<Text as="h1" size="2xl" weight="bold" className="text-gray-900">
الإدارة المالية
</Text>
<Text color="secondary" className="mt-2">
إدارة الإيرادات والمصروفات والتقارير المالية
</Text>
</div>
<Card>
<CardHeader>
<Text weight="medium">التقارير المالية</Text>
</CardHeader>
<CardBody>
<div className="text-center py-12">
<svg
className="mx-auto h-12 w-12 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
/>
</svg>
<Text size="lg" weight="medium" className="mb-2">
لا توجد بيانات مالية
</Text>
<Text color="secondary">
سيتم إضافة وظائف الإدارة المالية في المهام القادمة
</Text>
</div>
</CardBody>
</Card>
</div>
</DashboardLayout>
);
}

11
app/routes/logout.tsx Normal file
View File

@@ -0,0 +1,11 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { logout } from "~/lib/auth.server";
export async function action({ request }: ActionFunctionArgs) {
return logout(request);
}
export async function loader({ request }: LoaderFunctionArgs) {
return logout(request);
}

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>
);
}

314
app/routes/settings.tsx Normal file
View File

@@ -0,0 +1,314 @@
import { json, type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node';
import { Form, useActionData, useLoaderData, useNavigation } from '@remix-run/react';
import { useState } from 'react';
import { requireAuth } from '~/lib/auth-middleware.server';
import { DashboardLayout } from '~/components/layout/DashboardLayout';
import {
getAppSettings,
updateSettings,
type AppSettings,
initializeDefaultSettings
} from '~/lib/settings-management.server';
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireAuth(request, 2); // Admin level required (1=superadmin, 2=admin)
try {
// Initialize default settings if needed
await initializeDefaultSettings();
const settings = await getAppSettings();
return json({ user, settings, success: true });
} catch (error) {
console.error('Settings loader error:', error);
return json({
user,
settings: null,
success: false,
error: 'Failed to load settings'
});
}
}
export async function action({ request }: ActionFunctionArgs) {
await requireAuth(request, 2); // Admin level required
const formData = await request.formData();
const intent = formData.get('intent');
if (intent === 'updateSettings') {
try {
const settings: Partial<AppSettings> = {
dateFormat: formData.get('dateFormat') as 'ar-SA' | 'en-US',
currency: formData.get('currency') as string,
numberFormat: formData.get('numberFormat') as 'ar-SA' | 'en-US',
currencySymbol: formData.get('currencySymbol') as string,
dateDisplayFormat: formData.get('dateDisplayFormat') as string,
};
await updateSettings(settings);
return json({
success: true,
message: 'Settings updated successfully'
});
} catch (error) {
console.error('Settings update error:', error);
return json({
success: false,
error: 'Failed to update settings'
});
}
}
return json({ success: false, error: 'Invalid action' });
}
export default function SettingsPage() {
const { user, settings, success, error } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
const [formData, setFormData] = useState<AppSettings>(
settings || {
dateFormat: 'ar-SA',
currency: 'JOD',
numberFormat: 'ar-SA',
currencySymbol: 'د.أ',
dateDisplayFormat: 'dd/MM/yyyy'
}
);
const handleInputChange = (key: keyof AppSettings, value: string) => {
setFormData(prev => ({ ...prev, [key]: value }));
};
if (!success || !settings) {
return (
<DashboardLayout user={user}>
<div className="container mx-auto px-4 py-8">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h2 className="text-lg font-semibold text-red-800 mb-2">خطأ في تحميل الإعدادات</h2>
<p className="text-red-600">{error || 'Failed to load settings'}</p>
</div>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout user={user}>
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow-md">
<div className="px-6 py-4 border-b border-gray-200">
<h1 className="text-2xl font-bold text-gray-900"> إعدادات النظام</h1>
<p className="text-gray-600 mt-1">تكوين إعدادات التطبيق العامة</p>
</div>
<div className="p-6">
{actionData?.success && (
<div className="mb-6 bg-green-50 border border-green-200 rounded-lg p-4">
<p className="text-green-800"> {actionData.message}</p>
</div>
)}
{actionData?.error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<p className="text-red-800"> {actionData.error}</p>
</div>
)}
<Form method="post" className="space-y-6">
<input type="hidden" name="intent" value="updateSettings" />
{/* Date Format Settings */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">📅 إعدادات التاريخ</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
تنسيق التاريخ
</label>
<select
name="dateFormat"
value={formData.dateFormat}
onChange={(e) => handleInputChange('dateFormat', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="ar-SA">عربي (ar-SA) - ٢٠٢٥/١١/٩</option>
<option value="en-US">إنجليزي (en-US) - 11/9/2025</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
نمط عرض التاريخ
</label>
<select
name="dateDisplayFormat"
value={formData.dateDisplayFormat}
onChange={(e) => handleInputChange('dateDisplayFormat', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="dd/MM/yyyy">يوم/شهر/سنة (09/11/2025)</option>
<option value="MM/dd/yyyy">شهر/يوم/سنة (11/09/2025)</option>
<option value="yyyy-MM-dd">سنة-شهر-يوم (2025-11-09)</option>
</select>
</div>
</div>
<div className="mt-4 p-3 bg-blue-50 rounded-md">
<p className="text-sm text-blue-800">
<strong>معاينة:</strong> {new Date().toLocaleDateString(formData.dateFormat)}
</p>
</div>
</div>
{/* Currency Settings */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">💰 إعدادات العملة</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
رمز العملة
</label>
<select
name="currency"
value={formData.currency}
onChange={(e) => handleInputChange('currency', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="JOD">دينار أردني (JOD)</option>
<option value="USD">دولار أمريكي (USD)</option>
<option value="EUR">يورو (EUR)</option>
<option value="SAR">ريال سعودي (SAR)</option>
<option value="AED">درهم إماراتي (AED)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
رمز العملة المعروض
</label>
<input
type="text"
name="currencySymbol"
value={formData.currencySymbol}
onChange={(e) => handleInputChange('currencySymbol', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="د.أ"
/>
</div>
</div>
<div className="mt-4 p-3 bg-blue-50 rounded-md">
<p className="text-sm text-blue-800">
<strong>معاينة:</strong> {(1234.56).toLocaleString(formData.numberFormat, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})} {formData.currencySymbol}
</p>
</div>
</div>
{/* Number Format Settings */}
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="text-lg font-semibold text-gray-900 mb-4">🔢 إعدادات الأرقام</h3>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
تنسيق الأرقام
</label>
<select
name="numberFormat"
value={formData.numberFormat}
onChange={(e) => handleInputChange('numberFormat', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="ar-SA">عربي (ar-SA) - ١٬٢٣٤٫٥٦</option>
<option value="en-US">إنجليزي (en-US) - 1,234.56</option>
</select>
</div>
<div className="mt-4 p-3 bg-blue-50 rounded-md">
<p className="text-sm text-blue-800">
<strong>معاينة الأرقام:</strong> {(123456.789).toLocaleString(formData.numberFormat)}
</p>
<p className="text-sm text-blue-800">
<strong>معاينة الكيلومترات:</strong> {(45000).toLocaleString(formData.numberFormat)} كم
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-4 pt-6 border-t border-gray-200">
<button
type="button"
onClick={() => setFormData(settings)}
className="px-4 py-2 text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300 transition-colors"
disabled={isSubmitting}
>
إعادة تعيين
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{isSubmitting ? 'جاري الحفظ...' : 'حفظ الإعدادات'}
</button>
</div>
</Form>
</div>
</div>
{/* Settings Preview Section */}
<div className="mt-8 bg-white rounded-lg shadow-md">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">👁 معاينة الإعدادات</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="bg-gray-50 p-4 rounded-lg">
<h4 className="font-medium text-gray-900 mb-2">التاريخ والوقت</h4>
<p className="text-sm text-gray-600">
التاريخ: {new Date().toLocaleDateString(formData.dateFormat)}
</p>
<p className="text-sm text-gray-600">
التاريخ والوقت: {new Date().toLocaleString(formData.dateFormat)}
</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<h4 className="font-medium text-gray-900 mb-2">العملة والأرقام</h4>
<p className="text-sm text-gray-600">
السعر: {(250.75).toLocaleString(formData.numberFormat, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})} {formData.currencySymbol}
</p>
<p className="text-sm text-gray-600">
الكيلومترات: {(87500).toLocaleString(formData.numberFormat)} كم
</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<h4 className="font-medium text-gray-900 mb-2">أرقام كبيرة</h4>
<p className="text-sm text-gray-600">
المبلغ الإجمالي: {(1234567.89).toLocaleString(formData.numberFormat, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})} {formData.currencySymbol}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</DashboardLayout>
);
}

282
app/routes/signin.tsx Normal file
View File

@@ -0,0 +1,282 @@
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, Link, useActionData, useLoaderData, useNavigation } from "@remix-run/react";
import { validateSignIn } from "~/lib/auth-helpers.server";
import { createUserSession, getUserId } from "~/lib/auth.server";
import { AUTH_ERRORS } from "~/lib/auth-constants";
import type { SignInFormData } from "~/types/auth";
export const meta: MetaFunction = () => {
return [
{ title: "تسجيل الدخول - نظام إدارة صيانة السيارات" },
{ name: "description", content: "تسجيل الدخول إلى نظام إدارة صيانة السيارات" },
];
};
export async function loader({ request }: LoaderFunctionArgs) {
// Import the redirect middleware
const { redirectIfAuthenticated } = await import("~/lib/auth-middleware.server");
await redirectIfAuthenticated(request);
const url = new URL(request.url);
const redirectTo = url.searchParams.get("redirectTo") || "/dashboard";
const error = url.searchParams.get("error");
return json({ redirectTo, error });
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const usernameOrEmail = formData.get("usernameOrEmail");
const password = formData.get("password");
const redirectTo = formData.get("redirectTo") || "/dashboard";
// Validate form data
if (
typeof usernameOrEmail !== "string" ||
typeof password !== "string" ||
typeof redirectTo !== "string"
) {
return json(
{
errors: [{ message: "بيانات النموذج غير صحيحة" }],
values: { usernameOrEmail: usernameOrEmail || "" }
},
{ status: 400 }
);
}
const signInData: SignInFormData = {
usernameOrEmail: usernameOrEmail.trim(),
password,
redirectTo,
};
// Validate credentials
const result = await validateSignIn(signInData);
if (!result.success) {
return json(
{
errors: result.errors || [{ message: AUTH_ERRORS.INVALID_CREDENTIALS }],
values: { usernameOrEmail: signInData.usernameOrEmail }
},
{ status: 400 }
);
}
if (!result.user) {
return json(
{
errors: [{ message: AUTH_ERRORS.INVALID_CREDENTIALS }],
values: { usernameOrEmail: signInData.usernameOrEmail }
},
{ status: 400 }
);
}
// Create session and redirect
return createUserSession(result.user.id, redirectTo);
}
export default function SignIn() {
const { redirectTo, error } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const getErrorMessage = (field?: string) => {
if (!actionData?.errors) return null;
const error = actionData.errors.find(e => e.field === field || !e.field);
return error?.message;
};
const getErrorForUrl = (errorParam: string | null) => {
switch (errorParam) {
case "account_inactive":
return AUTH_ERRORS.ACCOUNT_INACTIVE;
case "session_expired":
return AUTH_ERRORS.SESSION_EXPIRED;
default:
return null;
}
};
const urlError = getErrorForUrl(error);
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8" dir="rtl">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
<svg
className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
تسجيل الدخول
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
أو{" "}
<Link
to="/signup"
className="font-medium text-blue-600 hover:text-blue-500"
>
إنشاء حساب جديد
</Link>
</p>
</div>
<Form className="mt-8 space-y-6" method="post">
<input type="hidden" name="redirectTo" value={redirectTo} />
{/* Display URL error */}
{urlError && (
<div className="rounded-md bg-red-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-red-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="mr-3">
<p className="text-sm text-red-800">{urlError}</p>
</div>
</div>
</div>
)}
{/* Display form errors */}
{getErrorMessage() && (
<div className="rounded-md bg-red-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-red-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="mr-3">
<p className="text-sm text-red-800">{getErrorMessage()}</p>
</div>
</div>
</div>
)}
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="usernameOrEmail" className="sr-only">
اسم المستخدم أو البريد الإلكتروني
</label>
<input
id="usernameOrEmail"
name="usernameOrEmail"
type="text"
autoComplete="username"
required
className={`appearance-none rounded-none relative block w-full px-3 py-2 border ${
getErrorMessage("usernameOrEmail")
? "border-red-300 text-red-900 placeholder-red-300 focus:outline-none focus:ring-red-500 focus:border-red-500"
: "border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
} rounded-t-md focus:z-10 sm:text-sm`}
placeholder="اسم المستخدم أو البريد الإلكتروني"
defaultValue={actionData?.values?.usernameOrEmail}
dir="ltr"
/>
</div>
<div>
<label htmlFor="password" className="sr-only">
كلمة المرور
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className={`appearance-none rounded-none relative block w-full px-3 py-2 border ${
getErrorMessage("password")
? "border-red-300 text-red-900 placeholder-red-300 focus:outline-none focus:ring-red-500 focus:border-red-500"
: "border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
} rounded-b-md focus:z-10 sm:text-sm`}
placeholder="كلمة المرور"
dir="ltr"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isSubmitting}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="absolute right-0 inset-y-0 flex items-center pr-3">
{isSubmitting ? (
<svg
className="animate-spin h-5 w-5 text-blue-300"
xmlns="http://www.w3.org/2000/svg"
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>
) : (
<svg
className="h-5 w-5 text-blue-500 group-hover:text-blue-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
clipRule="evenodd"
/>
</svg>
)}
</span>
{isSubmitting ? "جاري تسجيل الدخول..." : "تسجيل الدخول"}
</button>
</div>
</Form>
</div>
</div>
);
}

413
app/routes/signup.tsx Normal file
View File

@@ -0,0 +1,413 @@
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, Link, useActionData, useLoaderData, useNavigation } from "@remix-run/react";
import { validateSignUp, createUser, isSignupAllowed } from "~/lib/auth-helpers.server";
import { createUserSession, getUserId } from "~/lib/auth.server";
import { AUTH_ERRORS } from "~/lib/auth-constants";
import type { SignUpFormData } from "~/types/auth";
export const meta: MetaFunction = () => {
return [
{ title: "إنشاء حساب - نظام إدارة صيانة السيارات" },
{ name: "description", content: "إنشاء حساب جديد في نظام إدارة صيانة السيارات" },
];
};
export async function loader({ request }: LoaderFunctionArgs) {
// Import the redirect middleware
const { redirectIfAuthenticated } = await import("~/lib/auth-middleware.server");
await redirectIfAuthenticated(request);
const url = new URL(request.url);
const adminOverride = url.searchParams.get("admin_override") === "true";
// Check if signup is allowed (only when no admin users exist or admin override)
const signupAllowed = await isSignupAllowed();
if (!signupAllowed && !adminOverride) {
return redirect("/signin?error=signup_disabled");
}
return json({ signupAllowed: signupAllowed || adminOverride });
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const adminOverride = formData.get("admin_override") === "true";
// Check if signup is still allowed
const signupAllowed = await isSignupAllowed();
if (!signupAllowed && !adminOverride) {
return json(
{
errors: [{ message: AUTH_ERRORS.SIGNUP_DISABLED }],
values: {}
},
{ status: 403 }
);
}
const name = formData.get("name");
const username = formData.get("username");
const email = formData.get("email");
const password = formData.get("password");
const confirmPassword = formData.get("confirmPassword");
// Validate form data types
if (
typeof name !== "string" ||
typeof username !== "string" ||
typeof email !== "string" ||
typeof password !== "string" ||
typeof confirmPassword !== "string"
) {
return json(
{
errors: [{ message: "بيانات النموذج غير صحيحة" }],
values: {
name: name || "",
username: username || "",
email: email || ""
}
},
{ status: 400 }
);
}
const signUpData: SignUpFormData = {
name: name.trim(),
username: username.trim(),
email: email.trim(),
password,
confirmPassword,
};
// Validate signup data
const validationResult = await validateSignUp(signUpData);
if (!validationResult.success) {
return json(
{
errors: validationResult.errors || [{ message: "فشل في التحقق من البيانات" }],
values: {
name: signUpData.name,
username: signUpData.username,
email: signUpData.email
}
},
{ status: 400 }
);
}
try {
// Create the user
const user = await createUser(signUpData);
// Create session and redirect to dashboard
return createUserSession(user.id, "/dashboard");
} catch (error) {
console.error("Error creating user:", error);
return json(
{
errors: [{ message: "حدث خطأ أثناء إنشاء الحساب" }],
values: {
name: signUpData.name,
username: signUpData.username,
email: signUpData.email
}
},
{ status: 500 }
);
}
}
export default function SignUp() {
const { signupAllowed } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
// Check if this is an admin override
const url = typeof window !== "undefined" ? new URL(window.location.href) : null;
const adminOverride = url?.searchParams.get("admin_override") === "true";
const getErrorMessage = (field?: string) => {
if (!actionData?.errors) return null;
const error = actionData.errors.find(e => e.field === field || (!e.field && !field));
return error?.message;
};
if (!signupAllowed) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8" dir="rtl">
<div className="max-w-md w-full space-y-8">
<div className="text-center">
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-red-100">
<svg
className="h-6 w-6 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
التسجيل غير متاح
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
التسجيل غير متاح حالياً. يرجى الاتصال بالمسؤول.
</p>
<div className="mt-6">
<Link
to="/signin"
className="font-medium text-blue-600 hover:text-blue-500"
>
العودة إلى تسجيل الدخول
</Link>
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8" dir="rtl">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-green-100">
<svg
className="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
/>
</svg>
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
إنشاء حساب جديد
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
أو{" "}
<Link
to="/signin"
className="font-medium text-blue-600 hover:text-blue-500"
>
تسجيل الدخول إلى حساب موجود
</Link>
</p>
</div>
<Form className="mt-8 space-y-6" method="post">
{adminOverride && (
<input type="hidden" name="admin_override" value="true" />
)}
{/* Display general errors */}
{getErrorMessage() && (
<div className="rounded-md bg-red-50 p-4">
<div className="flex">
<div className="flex-shrink-0">
<svg
className="h-5 w-5 text-red-400"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="mr-3">
<p className="text-sm text-red-800">{getErrorMessage()}</p>
</div>
</div>
</div>
)}
<div className="space-y-4">
{/* Name field */}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
الاسم الكامل
</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
required
className={`mt-1 appearance-none relative block w-full px-3 py-2 border ${
getErrorMessage("name")
? "border-red-300 text-red-900 placeholder-red-300 focus:outline-none focus:ring-red-500 focus:border-red-500"
: "border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
} rounded-md focus:z-10 sm:text-sm`}
placeholder="أدخل اسمك الكامل"
defaultValue={actionData?.values?.name}
/>
{getErrorMessage("name") && (
<p className="mt-1 text-sm text-red-600">{getErrorMessage("name")}</p>
)}
</div>
{/* Username field */}
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
اسم المستخدم
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
required
className={`mt-1 appearance-none relative block w-full px-3 py-2 border ${
getErrorMessage("username")
? "border-red-300 text-red-900 placeholder-red-300 focus:outline-none focus:ring-red-500 focus:border-red-500"
: "border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
} rounded-md focus:z-10 sm:text-sm`}
placeholder="أدخل اسم المستخدم"
defaultValue={actionData?.values?.username}
dir="ltr"
/>
{getErrorMessage("username") && (
<p className="mt-1 text-sm text-red-600">{getErrorMessage("username")}</p>
)}
</div>
{/* Email field */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
البريد الإلكتروني
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className={`mt-1 appearance-none relative block w-full px-3 py-2 border ${
getErrorMessage("email")
? "border-red-300 text-red-900 placeholder-red-300 focus:outline-none focus:ring-red-500 focus:border-red-500"
: "border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
} rounded-md focus:z-10 sm:text-sm`}
placeholder="أدخل بريدك الإلكتروني"
defaultValue={actionData?.values?.email}
dir="ltr"
/>
{getErrorMessage("email") && (
<p className="mt-1 text-sm text-red-600">{getErrorMessage("email")}</p>
)}
</div>
{/* Password field */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
كلمة المرور
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
className={`mt-1 appearance-none relative block w-full px-3 py-2 border ${
getErrorMessage("password")
? "border-red-300 text-red-900 placeholder-red-300 focus:outline-none focus:ring-red-500 focus:border-red-500"
: "border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
} rounded-md focus:z-10 sm:text-sm`}
placeholder="أدخل كلمة المرور (6 أحرف على الأقل)"
dir="ltr"
/>
{getErrorMessage("password") && (
<p className="mt-1 text-sm text-red-600">{getErrorMessage("password")}</p>
)}
</div>
{/* Confirm Password field */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
تأكيد كلمة المرور
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
className={`mt-1 appearance-none relative block w-full px-3 py-2 border ${
getErrorMessage("confirmPassword")
? "border-red-300 text-red-900 placeholder-red-300 focus:outline-none focus:ring-red-500 focus:border-red-500"
: "border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-blue-500 focus:border-blue-500"
} rounded-md focus:z-10 sm:text-sm`}
placeholder="أعد إدخال كلمة المرور"
dir="ltr"
/>
{getErrorMessage("confirmPassword") && (
<p className="mt-1 text-sm text-red-600">{getErrorMessage("confirmPassword")}</p>
)}
</div>
</div>
<div>
<button
type="submit"
disabled={isSubmitting}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="absolute right-0 inset-y-0 flex items-center pr-3">
{isSubmitting ? (
<svg
className="animate-spin h-5 w-5 text-green-300"
xmlns="http://www.w3.org/2000/svg"
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>
) : (
<svg
className="h-5 w-5 text-green-500 group-hover:text-green-400"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clipRule="evenodd"
/>
</svg>
)}
</span>
{isSubmitting ? "جاري إنشاء الحساب..." : "إنشاء الحساب"}
</button>
</div>
</Form>
</div>
</div>
);
}

382
app/routes/users.tsx Normal file
View File

@@ -0,0 +1,382 @@
import type { LoaderFunctionArgs, ActionFunctionArgs, MetaFunction } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { useLoaderData, useSearchParams, useNavigation, useActionData } from "@remix-run/react";
import { useState, useEffect, useCallback } from "react";
import { protectUserManagementRoute } from "~/lib/auth-middleware.server";
import { getUsers, createUser, updateUser, deleteUser, toggleUserStatus } from "~/lib/user-management.server";
import { DashboardLayout } from "~/components/layout/DashboardLayout";
import { Text, Card, CardHeader, CardBody, Button, SearchInput, Modal } from "~/components/ui";
import { UserList } from "~/components/users/UserList";
import { UserForm } from "~/components/users/UserForm";
import type { UserWithoutPassword } from "~/types/database";
export const meta: MetaFunction = () => {
return [
{ title: "إدارة المستخدمين - نظام إدارة صيانة السيارات" },
{ name: "description", content: "إدارة حسابات المستخدمين" },
];
};
export async function loader({ request }: LoaderFunctionArgs) {
const user = await protectUserManagementRoute(request);
const url = new URL(request.url);
const searchQuery = url.searchParams.get("search") || "";
const page = parseInt(url.searchParams.get("page") || "1");
const limit = 10;
const { users, total, totalPages } = await getUsers(
user.authLevel,
searchQuery,
page,
limit
);
return json({
user,
users,
currentPage: page,
totalPages,
total,
searchQuery,
});
}
export async function action({ request }: ActionFunctionArgs) {
const user = await protectUserManagementRoute(request);
const formData = await request.formData();
const action = formData.get("_action") as string;
try {
switch (action) {
case "create": {
const userData = {
name: formData.get("name") as string,
username: formData.get("username") as string,
email: formData.get("email") as string,
password: formData.get("password") as string,
authLevel: parseInt(formData.get("authLevel") as string),
status: formData.get("status") as string,
};
const result = await createUser(userData, user.authLevel);
if (result.success) {
return json({ success: true, message: "تم إنشاء المستخدم بنجاح" });
} else {
return json({ success: false, error: result.error }, { status: 400 });
}
}
case "update": {
const userId = parseInt(formData.get("userId") as string);
const userData = {
name: formData.get("name") as string,
username: formData.get("username") as string,
email: formData.get("email") as string,
authLevel: parseInt(formData.get("authLevel") as string),
status: formData.get("status") as string,
};
const password = formData.get("password") as string;
if (password) {
(userData as any).password = password;
}
const result = await updateUser(userId, userData, user.authLevel);
if (result.success) {
return json({ success: true, message: "تم تحديث المستخدم بنجاح" });
} else {
return json({ success: false, error: result.error }, { status: 400 });
}
}
case "delete": {
const userId = parseInt(formData.get("userId") as string);
const result = await deleteUser(userId, user.authLevel);
if (result.success) {
return json({ success: true, message: "تم حذف المستخدم بنجاح" });
} else {
return json({ success: false, error: result.error }, { status: 400 });
}
}
case "toggle-status": {
const userId = parseInt(formData.get("userId") as string);
const result = await toggleUserStatus(userId, user.authLevel);
if (result.success) {
return json({ success: true, message: "تم تغيير حالة المستخدم بنجاح" });
} else {
return json({ success: false, error: result.error }, { status: 400 });
}
}
default:
return json({ success: false, error: "إجراء غير صحيح" }, { status: 400 });
}
} catch (error) {
console.error("User management action error:", error);
return json({ success: false, error: "حدث خطأ في الخادم" }, { status: 500 });
}
}
export default function Users() {
const { user, users, currentPage, totalPages, total, searchQuery } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const navigation = useNavigation();
const actionData = useActionData<typeof action>();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingUser, setEditingUser] = useState<UserWithoutPassword | null>(null);
const [notification, setNotification] = useState<{
type: 'success' | 'error';
message: string;
} | null>(null);
const isLoading = navigation.state === "loading";
const isSubmitting = navigation.state === "submitting";
// Handle action results
useEffect(() => {
if (actionData) {
if (actionData.success) {
setNotification({
type: 'success',
message: actionData.message || 'تم تنفيذ العملية بنجاح',
});
setShowCreateModal(false);
setEditingUser(null);
} else {
setNotification({
type: 'error',
message: actionData.error || 'حدث خطأ أثناء تنفيذ العملية',
});
}
}
}, [actionData]);
// Clear notification after 5 seconds
useEffect(() => {
if (notification) {
const timer = setTimeout(() => {
setNotification(null);
}, 5000);
return () => clearTimeout(timer);
}
}, [notification]);
const handleSearch = useCallback((query: string) => {
const newSearchParams = new URLSearchParams(searchParams);
if (query) {
newSearchParams.set("search", query);
} else {
newSearchParams.delete("search");
}
newSearchParams.delete("page"); // Reset to first page
setSearchParams(newSearchParams);
}, [searchParams, setSearchParams]);
const handlePageChange = useCallback((page: number) => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set("page", page.toString());
setSearchParams(newSearchParams);
}, [searchParams, setSearchParams]);
const handleEdit = useCallback((userToEdit: UserWithoutPassword) => {
setEditingUser(userToEdit);
}, []);
const handleDelete = useCallback((userId: number) => {
// Create a form and submit it
const form = document.createElement("form");
form.method = "POST";
form.style.display = "none";
const actionInput = document.createElement("input");
actionInput.type = "hidden";
actionInput.name = "_action";
actionInput.value = "delete";
form.appendChild(actionInput);
const userIdInput = document.createElement("input");
userIdInput.type = "hidden";
userIdInput.name = "userId";
userIdInput.value = userId.toString();
form.appendChild(userIdInput);
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}, []);
const handleToggleStatus = useCallback((userId: number) => {
// Create a form and submit it
const form = document.createElement("form");
form.method = "POST";
form.style.display = "none";
const actionInput = document.createElement("input");
actionInput.type = "hidden";
actionInput.name = "_action";
actionInput.value = "toggle-status";
form.appendChild(actionInput);
const userIdInput = document.createElement("input");
userIdInput.type = "hidden";
userIdInput.name = "userId";
userIdInput.value = userId.toString();
form.appendChild(userIdInput);
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}, []);
const handleFormSubmit = useCallback((formData: FormData) => {
// Create a form and submit it
const form = document.createElement("form");
form.method = "POST";
form.style.display = "none";
for (const [key, value] of formData.entries()) {
const input = document.createElement("input");
input.type = "hidden";
input.name = key;
input.value = value as string;
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}, []);
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>
<Text color="secondary" className="mt-2">
إدارة حسابات المستخدمين وصلاحيات الوصول ({total} مستخدم)
</Text>
</div>
<Button onClick={() => setShowCreateModal(true)}>
إضافة مستخدم جديد
</Button>
</div>
{/* Notification */}
{notification && (
<div
className={`p-4 rounded-md ${notification.type === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'
}`}
>
<div className="flex">
<div className="flex-shrink-0">
{notification.type === 'success' ? (
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
) : (
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
)}
</div>
<div className="mr-3">
<Text size="sm">{notification.message}</Text>
</div>
<div className="mr-auto pl-3">
<button
onClick={() => setNotification(null)}
className="inline-flex text-gray-400 hover:text-gray-600"
>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
)}
{/* Search and Filters */}
<Card>
<CardBody>
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<SearchInput
placeholder="البحث في المستخدمين..."
onSearch={handleSearch}
initialValue={searchQuery}
/>
</div>
</div>
</CardBody>
</Card>
{/* Users List */}
<Card>
<CardHeader>
<Text weight="medium">قائمة المستخدمين</Text>
</CardHeader>
<CardBody padding="none">
<UserList
users={users}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
onEdit={handleEdit}
onDelete={handleDelete}
onToggleStatus={handleToggleStatus}
currentUserAuthLevel={user.authLevel}
loading={isLoading}
/>
</CardBody>
</Card>
{/* Create User Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="إضافة مستخدم جديد"
size="lg"
>
<UserForm
onSubmit={handleFormSubmit}
onCancel={() => setShowCreateModal(false)}
loading={isSubmitting}
currentUserAuthLevel={user.authLevel}
/>
</Modal>
{/* Edit User Modal */}
<Modal
isOpen={!!editingUser}
onClose={() => setEditingUser(null)}
title="تعديل المستخدم"
size="lg"
>
{editingUser && (
<UserForm
user={editingUser}
onSubmit={handleFormSubmit}
onCancel={() => setEditingUser(null)}
loading={isSubmitting}
currentUserAuthLevel={user.authLevel}
/>
)}
</Modal>
</div>
</DashboardLayout>
);
}

497
app/routes/vehicles.tsx Normal file
View File

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