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

363
app/components/README.md Normal file
View File

@@ -0,0 +1,363 @@
# Reusable Form Components and Validation
This document describes the enhanced reusable form components and validation utilities implemented for the car maintenance management system.
## Overview
The system now includes a comprehensive set of reusable form components with RTL support, client-side and server-side validation, and enhanced data table functionality with Arabic text support.
## Components
### Form Input Components
#### Input Component (`app/components/ui/Input.tsx`)
Enhanced input component with RTL support, validation, and icons.
```tsx
import { Input } from '~/components/ui/Input';
<Input
label="اسم العميل"
placeholder="أدخل اسم العميل"
error={errors.name}
helperText="الاسم مطلوب"
startIcon={<UserIcon />}
required
/>
```
**Props:**
- `label`: Field label
- `error`: Error message to display
- `helperText`: Helper text below input
- `startIcon`/`endIcon`: Icons for input decoration
- `fullWidth`: Whether input takes full width (default: true)
- All standard HTML input props
#### Select Component (`app/components/ui/Select.tsx`)
Dropdown select component with RTL support and validation.
```tsx
import { Select } from '~/components/ui/Select';
<Select
label="نوع الوقود"
placeholder="اختر نوع الوقود"
options={[
{ value: 'gasoline', label: 'بنزين' },
{ value: 'diesel', label: 'ديزل' },
]}
error={errors.fuel}
/>
```
**Props:**
- `options`: Array of `{ value, label, disabled? }` objects
- `placeholder`: Placeholder text
- Other props same as Input component
#### Textarea Component (`app/components/ui/Textarea.tsx`)
Multi-line text input with RTL support.
```tsx
import { Textarea } from '~/components/ui/Textarea';
<Textarea
label="العنوان"
placeholder="أدخل العنوان"
rows={3}
resize="vertical"
error={errors.address}
/>
```
**Props:**
- `resize`: Resize behavior ('none', 'vertical', 'horizontal', 'both')
- `rows`: Number of visible rows
- Other props same as Input component
#### FormField Component (`app/components/ui/FormField.tsx`)
Wrapper component for consistent field styling and validation display.
```tsx
import { FormField } from '~/components/ui/FormField';
<FormField
label="اسم العميل"
required
error={errors.name}
helperText="أدخل الاسم الكامل"
>
<input type="text" name="name" />
</FormField>
```
### Form Layout Components
#### Form Component (`app/components/ui/Form.tsx`)
Main form wrapper with title, description, and error handling.
```tsx
import { Form, FormActions, FormSection, FormGrid } from '~/components/ui/Form';
<Form
title="إضافة عميل جديد"
description="أدخل بيانات العميل"
loading={isLoading}
error={generalError}
success={successMessage}
>
<FormSection title="المعلومات الأساسية">
<FormGrid columns={2}>
{/* Form fields */}
</FormGrid>
</FormSection>
<FormActions>
<Button variant="outline">إلغاء</Button>
<Button type="submit">حفظ</Button>
</FormActions>
</Form>
```
**Components:**
- `Form`: Main form wrapper
- `FormActions`: Action buttons container
- `FormSection`: Grouped form fields with title
- `FormGrid`: Responsive grid layout for fields
### Enhanced Data Table
#### DataTable Component (`app/components/ui/DataTable.tsx`)
Advanced data table with search, filtering, sorting, and pagination.
```tsx
import { DataTable } from '~/components/ui/DataTable';
<DataTable
data={customers}
columns={[
{
key: 'name',
header: 'الاسم',
sortable: true,
filterable: true,
render: (customer) => <strong>{customer.name}</strong>
},
{
key: 'phone',
header: 'الهاتف',
filterable: true,
filterType: 'text'
}
]}
searchable
searchPlaceholder="البحث في العملاء..."
filterable
pagination={{
enabled: true,
pageSize: 10,
currentPage: 1,
onPageChange: handlePageChange
}}
actions={{
label: 'الإجراءات',
render: (item) => (
<Button onClick={() => edit(item)}>تعديل</Button>
)
}}
/>
```
**Features:**
- Search across multiple fields
- Column-based filtering
- Sorting with Arabic text support
- Pagination
- Custom action buttons
- RTL layout support
- Loading and empty states
## Validation
### Server-Side Validation (`app/lib/form-validation.ts`)
Zod-based validation schemas with Arabic error messages.
```tsx
import { validateCustomerData } from '~/lib/form-validation';
const result = validateCustomerData(formData);
if (!result.success) {
return json({ errors: result.errors });
}
```
**Available Validators:**
- `validateUserData(data)`
- `validateCustomerData(data)`
- `validateVehicleData(data)`
- `validateMaintenanceVisitData(data)`
- `validateExpenseData(data)`
### Client-Side Validation Hook (`app/hooks/useFormValidation.ts`)
React hook for real-time form validation.
```tsx
import { useFormValidation } from '~/hooks/useFormValidation';
import { customerSchema } from '~/lib/form-validation';
const {
values,
errors,
isValid,
setValue,
getFieldProps,
validate
} = useFormValidation({
schema: customerSchema,
initialValues: { name: '', email: '' },
validateOnChange: true,
validateOnBlur: true
});
// Use with form fields
<Input {...getFieldProps('name')} />
```
### Validation Utilities (`app/lib/validation-utils.ts`)
Utility functions for field-level validation.
```tsx
import { validateField, validateEmail, PATTERNS } from '~/lib/validation-utils';
// Single field validation
const result = validateField(value, {
required: true,
minLength: 3,
email: true
});
// Specific validators
const emailResult = validateEmail('test@example.com');
const phoneResult = validatePhone('+966501234567');
// Pattern matching
const isValidEmail = PATTERNS.email.test(email);
```
## Table Utilities (`app/lib/table-utils.ts`)
Utilities for data processing with Arabic text support.
```tsx
import {
searchData,
filterData,
sortData,
processTableData
} from '~/lib/table-utils';
// Process table data with search, filter, sort, and pagination
const result = processTableData(
data,
{
search: 'محمد',
filters: { status: 'active' },
sort: { key: 'name', direction: 'asc' },
pagination: { page: 1, pageSize: 10 }
},
['name', 'email'] // searchable fields
);
```
## Example Forms
### Enhanced Customer Form (`app/components/forms/EnhancedCustomerForm.tsx`)
Complete example showing all components working together:
```tsx
import { EnhancedCustomerForm } from '~/components/forms/EnhancedCustomerForm';
<EnhancedCustomerForm
customer={customer}
onCancel={() => setShowForm(false)}
errors={actionData?.errors}
isLoading={navigation.state === 'submitting'}
onSubmit={(data) => submit(data, { method: 'post' })}
/>
```
### Enhanced Vehicle Form (`app/components/forms/EnhancedVehicleForm.tsx`)
Complex form with multiple sections and validation:
```tsx
import { EnhancedVehicleForm } from '~/components/forms/EnhancedVehicleForm';
<EnhancedVehicleForm
vehicle={vehicle}
customers={customers}
onCancel={() => setShowForm(false)}
errors={actionData?.errors}
isLoading={isSubmitting}
/>
```
## Features
### RTL Support
- All components support right-to-left layout
- Arabic text rendering and alignment
- Proper icon and element positioning
### Validation
- Client-side real-time validation
- Server-side validation with Zod schemas
- Arabic error messages
- Field-level and form-level validation
### Accessibility
- Proper ARIA labels and descriptions
- Keyboard navigation support
- Screen reader compatibility
- Focus management
### Performance
- Memoized components to prevent unnecessary re-renders
- Debounced search functionality
- Efficient data processing utilities
- Lazy loading for large datasets
## Usage Guidelines
1. **Always use FormField wrapper** for consistent styling and error display
2. **Implement both client and server validation** for security and UX
3. **Use the validation hook** for real-time feedback
4. **Leverage table utilities** for consistent data processing
5. **Follow RTL design patterns** for Arabic text and layout
6. **Test with Arabic content** to ensure proper rendering
## Migration from Old Components
To migrate existing forms to use the new components:
1. Replace basic inputs with the new Input/Select/Textarea components
2. Wrap fields with FormField for consistent styling
3. Add validation using the useFormValidation hook
4. Update data tables to use the enhanced DataTable component
5. Use Form layout components for better structure
## Testing
All components include comprehensive tests:
- `app/lib/__tests__/form-validation.test.ts`
- `app/lib/__tests__/validation-utils.test.ts`
Run tests with:
```bash
npm run test -- --run app/lib/__tests__/form-validation.test.ts
```

View File

@@ -0,0 +1,313 @@
import { Link } from "@remix-run/react";
import { Button } from "~/components/ui/Button";
import { Flex } from "~/components/layout/Flex";
import { useSettings } from "~/contexts/SettingsContext";
import { PAYMENT_STATUS_NAMES } from "~/lib/constants";
import type { CustomerWithVehicles } from "~/types/database";
interface CustomerDetailsViewProps {
customer: CustomerWithVehicles;
onEdit: () => void;
onClose: () => void;
}
export function CustomerDetailsView({
customer,
onEdit,
onClose,
}: CustomerDetailsViewProps) {
const { formatDate, formatCurrency } = useSettings();
return (
<div className="space-y-6">
{/* Enhanced Basic Information Section */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 p-6 rounded-xl border border-blue-100">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-gray-900 flex items-center">
<span className="text-blue-600 ml-2">👤</span>
المعلومات الأساسية
</h3>
<span className="text-sm text-gray-500 bg-white px-3 py-1 rounded-full">
العميل #{customer.id}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="bg-white p-4 rounded-lg shadow-sm">
<label className="block text-sm font-medium text-gray-600 mb-1">اسم العميل</label>
<p className="text-lg font-semibold text-gray-900">{customer.name}</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-sm">
<label className="block text-sm font-medium text-gray-600 mb-1">رقم الهاتف</label>
<p className="text-gray-900" dir="ltr">
{customer.phone ? (
<a
href={`tel:${customer.phone}`}
className="text-blue-600 hover:text-blue-800 font-medium"
>
📞 {customer.phone}
</a>
) : (
<span className="text-gray-400">غير محدد</span>
)}
</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-sm">
<label className="block text-sm font-medium text-gray-600 mb-1">البريد الإلكتروني</label>
<p className="text-gray-900" dir="ltr">
{customer.email ? (
<a
href={`mailto:${customer.email}`}
className="text-blue-600 hover:text-blue-800 font-medium"
>
{customer.email}
</a>
) : (
<span className="text-gray-400">غير محدد</span>
)}
</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-sm">
<label className="block text-sm font-medium text-gray-600 mb-1">تاريخ الإنشاء</label>
<p className="text-gray-900">
{formatDate(customer.createdDate)}
</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-sm">
<label className="block text-sm font-medium text-gray-600 mb-1">آخر تحديث</label>
<p className="text-gray-900">
{formatDate(customer.updateDate)}
</p>
</div>
{customer.address && (
<div className="bg-white p-4 rounded-lg shadow-sm md:col-span-2 lg:col-span-1">
<label className="block text-sm font-medium text-gray-600 mb-1">العنوان</label>
<p className="text-gray-900">{customer.address}</p>
</div>
)}
</div>
</div>
{/* Customer Vehicles Section */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<Flex justify="between" align="center" className="flex-wrap gap-4">
<div className="flex items-center">
<span className="text-gray-600 text-xl ml-2">🚗</span>
<h3 className="text-lg font-semibold text-gray-900">
مركبات العميل ({customer.vehicles.length})
</h3>
</div>
{customer.vehicles.length > 0 && (
<Link
to={`/vehicles?customerId=${customer.id}`}
className="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors"
>
<span className="ml-2">🔍</span>
عرض جميع المركبات
</Link>
)}
</Flex>
</div>
<div className="p-6">
{customer.vehicles.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 text-4xl mb-4">🚗</div>
<h4 className="text-lg font-medium text-gray-900 mb-2">لا توجد مركبات مسجلة</h4>
<p className="text-gray-500 mb-4">لم يتم تسجيل أي مركبات لهذا العميل بعد</p>
<Link
to="/vehicles"
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
إضافة مركبة جديدة
</Link>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{customer.vehicles.map((vehicle) => (
<Link
key={vehicle.id}
to={`/vehicles?plateNumber=${encodeURIComponent(vehicle.plateNumber)}`}
target="_blank"
className="block bg-gray-50 rounded-lg p-4 border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-all"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h4 className="font-semibold text-gray-900 text-lg">
{vehicle.plateNumber}
</h4>
<p className="text-sm text-gray-500">#{vehicle.id}</p>
</div>
<span className="text-xs text-blue-600 bg-blue-100 px-2 py-1 rounded-full">
انقر للعرض
</span>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-600">الصانع:</span>
<span className="text-sm font-medium text-gray-900">{vehicle.manufacturer}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">الموديل:</span>
<span className="text-sm font-medium text-gray-900">{vehicle.model}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">سنة الصنع:</span>
<span className="text-sm font-medium text-gray-900">{vehicle.year}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-gray-600">آخر زيارة:</span>
<span className="text-sm text-gray-900">
{vehicle.lastVisitDate
? formatDate(vehicle.lastVisitDate)
: <span className="text-gray-400">لا توجد زيارات</span>
}
</span>
</div>
</div>
</Link>
))}
</div>
)}
</div>
</div>
{/* Latest Maintenance Visits Section */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<Flex justify="between" align="center" className="flex-wrap gap-4">
<div className="flex items-center">
<span className="text-gray-600 text-xl ml-2">🔧</span>
<h3 className="text-lg font-semibold text-gray-900">
آخر زيارات الصيانة ({customer.maintenanceVisits.length > 3 ? '3 من ' + customer.maintenanceVisits.length : customer.maintenanceVisits.length})
</h3>
</div>
{customer.maintenanceVisits.length > 0 && (
<Link
to={`/maintenance-visits?customerId=${customer.id}`}
className="inline-flex items-center px-4 py-2 text-sm font-medium text-green-600 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
>
<span className="ml-2">📋</span>
عرض جميع الزيارات
</Link>
)}
</Flex>
</div>
<div className="p-6">
{customer.maintenanceVisits.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 text-4xl mb-4">🔧</div>
<h4 className="text-lg font-medium text-gray-900 mb-2">لا توجد زيارات صيانة</h4>
<p className="text-gray-500 mb-4">لم يتم تسجيل أي زيارات صيانة لهذا العميل بعد</p>
<p className="text-sm text-gray-400 mb-4">
ابدأ بتسجيل أول زيارة صيانة لتتبع تاريخ الخدمات المقدمة
</p>
<Link
to="/maintenance-visits"
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
>
تسجيل زيارة صيانة جديدة
</Link>
</div>
) : (
<div className="space-y-4">
{customer.maintenanceVisits.slice(0, 3).map((visit) => (
<div
key={visit.id}
className="bg-gray-50 rounded-lg p-4 border border-gray-200 hover:border-green-300 hover:bg-green-50 transition-all"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h4 className="font-semibold text-gray-900 text-lg">
{(() => {
try {
const jobs = JSON.parse(visit.maintenanceJobs);
return jobs.length > 1
? `${jobs.length} أعمال صيانة`
: jobs[0]?.job || 'نوع صيانة غير محدد';
} catch {
return 'نوع صيانة غير محدد';
}
})()}
</h4>
<p className="text-sm text-gray-500">زيارة #{visit.id}</p>
</div>
<div className="text-left">
<div className="text-lg font-bold text-green-600">
{formatCurrency(visit.cost)}
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">تاريخ الزيارة:</span>
<span className="font-medium text-gray-900">
{formatDate(visit.visitDate)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">المركبة:</span>
<span className="font-medium text-gray-900">
{visit.vehicle?.plateNumber || ''}
</span>
</div>
{visit.description && (
<div className="md:col-span-2">
<span className="text-gray-600">الوصف:</span>
<p className="text-gray-900 mt-1">{visit.description}</p>
</div>
)}
</div>
</div>
))}
{customer.maintenanceVisits.length > 3 && (
<div className="text-center py-4 border-t border-gray-200">
<p className="text-sm text-gray-500 mb-3">
عرض 3 من أصل {customer.maintenanceVisits.length} زيارة صيانة
</p>
<Link
to={`/maintenance-visits?customerId=${customer.id}`}
className="inline-flex items-center px-4 py-2 text-sm font-medium text-green-600 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
>
<span className="ml-2">📋</span>
عرض جميع الزيارات ({customer.maintenanceVisits.length})
</Link>
</div>
)}
</div>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-200">
<Button
onClick={onEdit}
className="bg-blue-600 hover:bg-blue-700 flex-1 sm:flex-none"
>
<span className="ml-2"></span>
تعديل العميل
</Button>
<Button
variant="outline"
onClick={onClose}
className="flex-1 sm:flex-none"
>
إغلاق
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,183 @@
import { Form } from "@remix-run/react";
import { useState, useEffect } from "react";
import { Input } from "~/components/ui/Input";
import { Button } from "~/components/ui/Button";
import { Flex } from "~/components/layout/Flex";
import type { Customer } from "~/types/database";
interface CustomerFormProps {
customer?: Customer;
onCancel: () => void;
errors?: Record<string, string>;
isLoading: boolean;
}
export function CustomerForm({
customer,
onCancel,
errors = {},
isLoading,
}: CustomerFormProps) {
const [formData, setFormData] = useState({
name: customer?.name || "",
phone: customer?.phone || "",
email: customer?.email || "",
address: customer?.address || "",
});
// Reset form data when customer changes
useEffect(() => {
if (customer) {
setFormData({
name: customer.name || "",
phone: customer.phone || "",
email: customer.email || "",
address: customer.address || "",
});
} else {
setFormData({
name: "",
phone: "",
email: "",
address: "",
});
}
}, [customer]);
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({
...prev,
[field]: value,
}));
};
const isEditing = !!customer;
return (
<Form method="post" className="space-y-6">
<input
type="hidden"
name="_action"
value={isEditing ? "update" : "create"}
/>
{isEditing && (
<input type="hidden" name="id" value={customer.id} />
)}
{/* Customer Name */}
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
اسم العميل *
</label>
<Input
id="name"
name="name"
type="text"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
placeholder="أدخل اسم العميل"
error={errors.name}
required
disabled={isLoading}
/>
{errors.name && (
<p className="mt-1 text-sm text-red-600">{errors.name}</p>
)}
</div>
{/* Phone Number */}
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-2">
رقم الهاتف
</label>
<Input
id="phone"
name="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleInputChange("phone", e.target.value)}
placeholder="أدخل رقم الهاتف"
error={errors.phone}
disabled={isLoading}
dir="ltr"
/>
{errors.phone && (
<p className="mt-1 text-sm text-red-600">{errors.phone}</p>
)}
</div>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
البريد الإلكتروني
</label>
<Input
id="email"
name="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
placeholder="أدخل البريد الإلكتروني"
error={errors.email}
disabled={isLoading}
dir="ltr"
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
)}
</div>
{/* Address */}
<div>
<label htmlFor="address" className="block text-sm font-medium text-gray-700 mb-2">
العنوان
</label>
<textarea
id="address"
name="address"
value={formData.address}
onChange={(e) => handleInputChange("address", e.target.value)}
placeholder="أدخل عنوان العميل"
rows={3}
disabled={isLoading}
className={`
w-full px-3 py-2 border rounded-lg shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:bg-gray-50 disabled:text-gray-500
${errors.address
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300'
}
`}
/>
{errors.address && (
<p className="mt-1 text-sm text-red-600">{errors.address}</p>
)}
</div>
{/* Form Actions */}
<Flex justify="end" className="pt-4 gap-2 border-t">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isLoading}
className="w-20"
>
إلغاء
</Button>
<Button
type="submit"
disabled={isLoading || !formData.name.trim()}
className="bg-blue-600 hover:bg-blue-700"
>
{isLoading
? (isEditing ? "جاري التحديث..." : "جاري الإنشاء...")
: (isEditing ? "تحديث العميل" : "إنشاء العميل")
}
</Button>
</Flex>
</Form>
);
}

View File

@@ -0,0 +1,282 @@
import { Form } from "@remix-run/react";
import { useState, useEffect } from "react";
import { Button } from "~/components/ui/Button";
import { Flex } from "~/components/layout/Flex";
import { useSettings } from "~/contexts/SettingsContext";
import type { CustomerWithVehicles } from "~/types/database";
interface CustomerListProps {
customers: CustomerWithVehicles[];
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
onViewCustomer: (customer: CustomerWithVehicles) => void;
onEditCustomer: (customer: CustomerWithVehicles) => void;
isLoading: boolean;
actionData?: any;
}
export function CustomerList({
customers,
currentPage,
totalPages,
onPageChange,
onViewCustomer,
onEditCustomer,
isLoading,
actionData,
}: CustomerListProps) {
const { formatDate } = useSettings();
const [deletingCustomerId, setDeletingCustomerId] = useState<number | null>(null);
// Reset deleting state when delete action completes
useEffect(() => {
if (actionData?.success && actionData.action === "delete") {
setDeletingCustomerId(null);
}
}, [actionData]);
const columns = [
{
key: "name",
header: "اسم العميل",
render: (customer: CustomerWithVehicles) => (
<div>
<div className="font-medium text-gray-900">{customer.name}</div>
<div className="text-sm text-gray-500">
{/* العميل رقم: {customer.id} */}
</div>
</div>
),
},
{
key: "contact",
header: "معلومات الاتصال",
render: (customer: CustomerWithVehicles) => (
<div className="space-y-1">
{customer.phone && (
<div className="text-sm text-gray-900" dir="ltr">
📞 {customer.phone}
</div>
)}
{customer.email && (
<div className="text-sm text-gray-600" dir="ltr">
{customer.email}
</div>
)}
{!customer.phone && !customer.email && (
<div className="text-sm text-gray-400">
لا توجد معلومات اتصال
</div>
)}
</div>
),
},
{
key: "address",
header: "العنوان",
render: (customer: CustomerWithVehicles) => (
<div className="text-sm text-gray-900">
{customer.address || (
<span className="text-gray-400">غير محدد</span>
)}
</div>
),
},
{
key: "vehicles",
header: "المركبات",
render: (customer: CustomerWithVehicles) => (
<div>
<div className="font-medium text-gray-900">
{customer.vehicles.length} مركبة
</div>
{customer.vehicles.length > 0 && (
<div className="text-sm text-gray-500 mt-1">
{customer.vehicles.slice(0, 2).map((vehicle) => (
<div key={vehicle.id}>
{vehicle.plateNumber} - {vehicle.manufacturer} {vehicle.model}
</div>
))}
{customer.vehicles.length > 2 && (
<div className="text-gray-400">
و {customer.vehicles.length - 2} مركبة أخرى...
</div>
)}
</div>
)}
</div>
),
},
{
key: "visits",
header: "الزيارات",
render: (customer: CustomerWithVehicles) => (
<div>
<div className="font-medium text-gray-900">
{customer.maintenanceVisits.length} زيارة
</div>
{customer.maintenanceVisits.length > 0 && (
<div className="text-sm text-gray-500">
آخر زيارة: {formatDate(customer.maintenanceVisits[0].visitDate)}
</div>
)}
</div>
),
},
{
key: "createdDate",
header: "تاريخ الإنشاء",
render: (customer: CustomerWithVehicles) => (
<div className="text-sm text-gray-600">
{formatDate(customer.createdDate)}
</div>
),
},
{
key: "actions",
header: "الإجراءات",
render: (customer: CustomerWithVehicles) => (
<Flex className="flex-wrap gap-2">
<Button
size="sm"
variant="outline"
className="bg-blue-50 text-blue-600 border-blue-300 hover:bg-blue-100"
disabled={isLoading}
onClick={() => onViewCustomer(customer)}
>
عرض
</Button>
<Button
size="sm"
variant="outline"
onClick={() => onEditCustomer(customer)}
disabled={isLoading}
>
تعديل
</Button>
<Form method="post" className="inline">
<input type="hidden" name="_action" value="delete" />
<input type="hidden" name="id" value={customer.id} />
<Button
type="submit"
size="sm"
variant="outline"
className="text-red-600 border-red-300 hover:bg-red-50"
disabled={isLoading || deletingCustomerId === customer.id}
onClick={(e) => {
e.preventDefault();
if (window.confirm("هل أنت متأكد من حذف هذا العميل؟")) {
setDeletingCustomerId(customer.id);
(e.target as HTMLButtonElement).form?.submit();
}
}}
>
{deletingCustomerId === customer.id ? "جاري الحذف..." : "حذف"}
</Button>
</Form>
</Flex>
),
},
];
return (
<div className="bg-white rounded-lg shadow-sm border">
{customers.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 text-lg mb-2">👥</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
لا يوجد عملاء
</h3>
<p className="text-gray-500">
لم يتم العثور على أي عملاء. قم بإضافة عميل جديد للبدء.
</p>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{columns.map((column) => (
<th
key={column.key}
className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{customers.map((customer) => (
<tr key={customer.id} className="hover:bg-gray-50">
{columns.map((column) => (
<td
key={`${customer.id}-${column.key}`}
className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
>
{column.render ? column.render(customer) : String(customer[column.key as keyof CustomerWithVehicles] || '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 space-x-reverse">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1 || isLoading}
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
السابق
</button>
<div className="flex items-center space-x-1 space-x-reverse">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
const page = i + 1;
return (
<button
key={page}
onClick={() => onPageChange(page)}
disabled={isLoading}
className={`px-3 py-2 text-sm font-medium rounded-md ${
currentPage === page
? 'bg-blue-600 text-white'
: 'text-gray-500 bg-white border border-gray-300 hover:bg-gray-50'
} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{page}
</button>
);
})}
</div>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages || isLoading}
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
التالي
</button>
</div>
<p className="text-sm text-gray-500">
صفحة {currentPage} من {totalPages}
</p>
</div>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,195 @@
import { Form } from "@remix-run/react";
import { useState, useEffect } from "react";
import { Input } from "~/components/ui/Input";
import { Button } from "~/components/ui/Button";
import { Flex } from "~/components/layout/Flex";
import { EXPENSE_CATEGORIES } from "~/lib/constants";
import type { Expense } from "@prisma/client";
interface ExpenseFormProps {
expense?: Expense;
onCancel: () => void;
errors?: Record<string, string>;
isLoading: boolean;
}
export function ExpenseForm({
expense,
onCancel,
errors = {},
isLoading,
}: ExpenseFormProps) {
const [formData, setFormData] = useState({
description: expense?.description || "",
category: expense?.category || "",
amount: expense?.amount?.toString() || "",
expenseDate: expense?.expenseDate
? new Date(expense.expenseDate).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0],
});
// Reset form data when expense changes
useEffect(() => {
if (expense) {
setFormData({
description: expense.description || "",
category: expense.category || "",
amount: expense.amount?.toString() || "",
expenseDate: expense.expenseDate
? new Date(expense.expenseDate).toISOString().split('T')[0]
: new Date().toISOString().split('T')[0],
});
} else {
setFormData({
description: "",
category: "",
amount: "",
expenseDate: new Date().toISOString().split('T')[0],
});
}
}, [expense]);
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({
...prev,
[field]: value,
}));
};
const isEditing = !!expense;
return (
<Form method="post" className="space-y-6">
<input
type="hidden"
name="_action"
value={isEditing ? "update" : "create"}
/>
{isEditing && (
<input type="hidden" name="id" value={expense.id} />
)}
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
وصف المصروف *
</label>
<Input
id="description"
name="description"
type="text"
value={formData.description}
onChange={(e) => handleInputChange("description", e.target.value)}
placeholder="أدخل وصف المصروف"
error={errors.description}
required
disabled={isLoading}
/>
{errors.description && (
<p className="mt-1 text-sm text-red-600">{errors.description}</p>
)}
</div>
{/* Category */}
<div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700 mb-2">
الفئة *
</label>
<select
id="category"
name="category"
value={formData.category}
onChange={(e) => handleInputChange("category", e.target.value)}
required
disabled={isLoading}
className={`
w-full px-3 py-2 border rounded-lg shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:bg-gray-50 disabled:text-gray-500
${errors.category
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300'
}
`}
>
<option value="">اختر الفئة</option>
{EXPENSE_CATEGORIES.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
{errors.category && (
<p className="mt-1 text-sm text-red-600">{errors.category}</p>
)}
</div>
{/* Amount */}
<div>
<label htmlFor="amount" className="block text-sm font-medium text-gray-700 mb-2">
المبلغ *
</label>
<Input
id="amount"
name="amount"
type="number"
step="0.01"
min="0.01"
value={formData.amount}
onChange={(e) => handleInputChange("amount", e.target.value)}
placeholder="0.00"
error={errors.amount}
required
disabled={isLoading}
dir="ltr"
/>
{errors.amount && (
<p className="mt-1 text-sm text-red-600">{errors.amount}</p>
)}
</div>
{/* Expense Date */}
<div>
<label htmlFor="expenseDate" className="block text-sm font-medium text-gray-700 mb-2">
تاريخ المصروف
</label>
<Input
id="expenseDate"
name="expenseDate"
type="date"
value={formData.expenseDate}
onChange={(e) => handleInputChange("expenseDate", e.target.value)}
error={errors.expenseDate}
disabled={isLoading}
/>
{errors.expenseDate && (
<p className="mt-1 text-sm text-red-600">{errors.expenseDate}</p>
)}
</div>
{/* Form Actions */}
<Flex justify="end" className="pt-4 gap-2 border-t">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isLoading}
className="w-20"
>
إلغاء
</Button>
<Button
type="submit"
disabled={isLoading || !formData.description.trim() || !formData.category || !formData.amount}
className="bg-blue-600 hover:bg-blue-700"
>
{isLoading
? (isEditing ? "جاري التحديث..." : "جاري الإنشاء...")
: (isEditing ? "تحديث المصروف" : "إنشاء المصروف")
}
</Button>
</Flex>
</Form>
);
}

View File

@@ -0,0 +1,199 @@
import { useEffect } from 'react';
import { Form as RemixForm } from "@remix-run/react";
import { Input } from "~/components/ui/Input";
import { Textarea } from "~/components/ui/Textarea";
import { Button } from "~/components/ui/Button";
import { FormField } from "~/components/ui/FormField";
import { Form, FormActions, FormSection, FormGrid } from "~/components/ui/Form";
import { useFormValidation } from "~/hooks/useFormValidation";
import { customerSchema } from "~/lib/form-validation";
import type { Customer } from "~/types/database";
interface EnhancedCustomerFormProps {
customer?: Customer;
onCancel: () => void;
errors?: Record<string, string>;
isLoading: boolean;
onSubmit?: (data: any) => void;
}
export function EnhancedCustomerForm({
customer,
onCancel,
errors = {},
isLoading,
onSubmit,
}: EnhancedCustomerFormProps) {
const {
values,
errors: validationErrors,
touched,
isValid,
setValue,
setTouched,
reset,
validate,
getFieldProps,
} = useFormValidation({
schema: customerSchema,
initialValues: {
name: customer?.name || "",
phone: customer?.phone || "",
email: customer?.email || "",
address: customer?.address || "",
},
validateOnChange: true,
validateOnBlur: true,
});
// Reset form when customer changes
useEffect(() => {
if (customer) {
reset({
name: customer.name || "",
phone: customer.phone || "",
email: customer.email || "",
address: customer.address || "",
});
} else {
reset({
name: "",
phone: "",
email: "",
address: "",
});
}
}, [customer, reset]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const { isValid: formIsValid } = validate();
if (formIsValid && onSubmit) {
onSubmit(values);
}
};
const isEditing = !!customer;
const combinedErrors = { ...validationErrors, ...errors };
return (
<Form
title={isEditing ? "تعديل بيانات العميل" : "إضافة عميل جديد"}
description={isEditing ? "قم بتعديل بيانات العميل أدناه" : "أدخل بيانات العميل الجديد"}
loading={isLoading}
onSubmit={handleSubmit}
>
<input
type="hidden"
name="_action"
value={isEditing ? "update" : "create"}
/>
{isEditing && (
<input type="hidden" name="id" value={customer.id} />
)}
<FormSection
title="المعلومات الأساسية"
description="البيانات الأساسية للعميل"
>
<FormGrid columns={2}>
{/* Customer Name */}
<FormField
label="اسم العميل"
required
error={combinedErrors.name}
htmlFor="name"
>
<Input
id="name"
name="name"
type="text"
placeholder="أدخل اسم العميل"
disabled={isLoading}
{...getFieldProps('name')}
/>
</FormField>
{/* Phone Number */}
<FormField
label="رقم الهاتف"
error={combinedErrors.phone}
htmlFor="phone"
helperText="رقم الهاتف اختياري"
>
<Input
id="phone"
name="phone"
type="tel"
placeholder="أدخل رقم الهاتف"
disabled={isLoading}
dir="ltr"
{...getFieldProps('phone')}
/>
</FormField>
</FormGrid>
</FormSection>
<FormSection
title="معلومات الاتصال"
description="بيانات الاتصال الإضافية"
>
{/* Email */}
<FormField
label="البريد الإلكتروني"
error={combinedErrors.email}
htmlFor="email"
helperText="البريد الإلكتروني اختياري"
>
<Input
id="email"
name="email"
type="email"
placeholder="أدخل البريد الإلكتروني"
disabled={isLoading}
dir="ltr"
{...getFieldProps('email')}
/>
</FormField>
{/* Address */}
<FormField
label="العنوان"
error={combinedErrors.address}
htmlFor="address"
helperText="عنوان العميل اختياري"
>
<Textarea
id="address"
name="address"
placeholder="أدخل عنوان العميل"
rows={3}
disabled={isLoading}
{...getFieldProps('address')}
/>
</FormField>
</FormSection>
<FormActions>
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isLoading}
>
إلغاء
</Button>
<Button
type="submit"
disabled={isLoading || !isValid || !values.name?.trim()}
loading={isLoading}
>
{isEditing ? "تحديث العميل" : "إنشاء العميل"}
</Button>
</FormActions>
</Form>
);
}

View File

@@ -0,0 +1,400 @@
import { useEffect } from 'react';
import { Form as RemixForm } from "@remix-run/react";
import { Input } from "~/components/ui/Input";
import { Select } from "~/components/ui/Select";
import { Button } from "~/components/ui/Button";
import { FormField } from "~/components/ui/FormField";
import { Form, FormActions, FormSection, FormGrid } from "~/components/ui/Form";
import { useFormValidation } from "~/hooks/useFormValidation";
import { vehicleSchema } from "~/lib/form-validation";
import { TRANSMISSION_TYPES, FUEL_TYPES, USE_TYPES, BODY_TYPES, MANUFACTURERS, VALIDATION } from "~/lib/constants";
import type { Vehicle } from "~/types/database";
interface EnhancedVehicleFormProps {
vehicle?: Vehicle;
customers: { id: number; name: string; phone?: string | null }[];
onCancel: () => void;
errors?: Record<string, string>;
isLoading: boolean;
onSubmit?: (data: any) => void;
}
export function EnhancedVehicleForm({
vehicle,
customers,
onCancel,
errors = {},
isLoading,
onSubmit,
}: EnhancedVehicleFormProps) {
const {
values,
errors: validationErrors,
touched,
isValid,
setValue,
setTouched,
reset,
validate,
getFieldProps,
} = useFormValidation({
schema: vehicleSchema,
initialValues: {
plateNumber: vehicle?.plateNumber || "",
bodyType: vehicle?.bodyType || "",
manufacturer: vehicle?.manufacturer || "",
model: vehicle?.model || "",
trim: vehicle?.trim || "",
year: vehicle?.year || new Date().getFullYear(),
transmission: vehicle?.transmission || "",
fuel: vehicle?.fuel || "",
cylinders: vehicle?.cylinders || null,
engineDisplacement: vehicle?.engineDisplacement || null,
useType: vehicle?.useType || "",
ownerId: vehicle?.ownerId || 0,
},
validateOnChange: true,
validateOnBlur: true,
});
// Reset form when vehicle changes
useEffect(() => {
if (vehicle) {
reset({
plateNumber: vehicle.plateNumber || "",
bodyType: vehicle.bodyType || "",
manufacturer: vehicle.manufacturer || "",
model: vehicle.model || "",
trim: vehicle.trim || "",
year: vehicle.year || new Date().getFullYear(),
transmission: vehicle.transmission || "",
fuel: vehicle.fuel || "",
cylinders: vehicle.cylinders || null,
engineDisplacement: vehicle.engineDisplacement || null,
useType: vehicle.useType || "",
ownerId: vehicle.ownerId || 0,
});
} else {
reset({
plateNumber: "",
bodyType: "",
manufacturer: "",
model: "",
trim: "",
year: new Date().getFullYear(),
transmission: "",
fuel: "",
cylinders: null,
engineDisplacement: null,
useType: "",
ownerId: 0,
});
}
}, [vehicle, reset]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const { isValid: formIsValid } = validate();
if (formIsValid && onSubmit) {
onSubmit(values);
}
};
const isEditing = !!vehicle;
const combinedErrors = { ...validationErrors, ...errors };
const currentYear = new Date().getFullYear();
return (
<Form
title={isEditing ? "تعديل بيانات المركبة" : "إضافة مركبة جديدة"}
description={isEditing ? "قم بتعديل بيانات المركبة أدناه" : "أدخل بيانات المركبة الجديدة"}
loading={isLoading}
onSubmit={handleSubmit}
>
<input
type="hidden"
name="_action"
value={isEditing ? "update" : "create"}
/>
{isEditing && (
<input type="hidden" name="id" value={vehicle.id} />
)}
<FormSection
title="المعلومات الأساسية"
description="البيانات الأساسية للمركبة"
>
<FormGrid columns={2}>
{/* Plate Number */}
<FormField
label="رقم اللوحة"
required
error={combinedErrors.plateNumber}
htmlFor="plateNumber"
>
<Input
id="plateNumber"
name="plateNumber"
type="text"
placeholder="أدخل رقم اللوحة"
disabled={isLoading}
dir="ltr"
{...getFieldProps('plateNumber')}
/>
</FormField>
{/* Owner */}
<FormField
label="المالك"
required
error={combinedErrors.ownerId}
htmlFor="ownerId"
>
<Select
id="ownerId"
name="ownerId"
placeholder="اختر المالك"
disabled={isLoading}
options={customers.map(customer => ({
value: customer.id.toString(),
label: `${customer.name}${customer.phone ? ` (${customer.phone})` : ''}`,
}))}
value={values.ownerId?.toString() || ''}
onChange={(e) => setValue('ownerId', parseInt(e.target.value) || 0)}
/>
</FormField>
</FormGrid>
</FormSection>
<FormSection
title="مواصفات المركبة"
description="التفاصيل التقنية للمركبة"
>
<FormGrid columns={3}>
{/* Body Type */}
<FormField
label="نوع الهيكل"
required
error={combinedErrors.bodyType}
htmlFor="bodyType"
>
<Select
id="bodyType"
name="bodyType"
placeholder="اختر نوع الهيكل"
aria-readonly={isLoading}
options={BODY_TYPES.map(type => ({
value: type.value,
label: type.label,
}))}
{...getFieldProps('bodyType')}
/>
</FormField>
{/* Manufacturer */}
<FormField
label="الشركة المصنعة"
required
error={combinedErrors.manufacturer}
htmlFor="manufacturer"
>
<Select
id="manufacturer"
name="manufacturer"
placeholder="اختر الشركة المصنعة"
disabled={isLoading}
options={MANUFACTURERS.map(manufacturer => ({
value: manufacturer.value,
label: manufacturer.label,
}))}
{...getFieldProps('manufacturer')}
/>
</FormField>
{/* Model */}
<FormField
label="الموديل"
required
error={combinedErrors.model}
htmlFor="model"
>
<Input
id="model"
name="model"
type="text"
placeholder="أدخل الموديل"
disabled={isLoading}
{...getFieldProps('model')}
/>
</FormField>
{/* Trim */}
<FormField
label="الفئة"
error={combinedErrors.trim}
htmlFor="trim"
helperText="الفئة اختيارية"
>
<Input
id="trim"
name="trim"
type="text"
placeholder="أدخل الفئة (اختياري)"
disabled={isLoading}
{...getFieldProps('trim')}
/>
</FormField>
{/* Year */}
<FormField
label="سنة الصنع"
required
error={combinedErrors.year}
htmlFor="year"
>
<Input
id="year"
name="year"
type="number"
min={VALIDATION.MIN_YEAR}
max={VALIDATION.MAX_YEAR}
placeholder={`${VALIDATION.MIN_YEAR} - ${currentYear}`}
disabled={isLoading}
value={values.year?.toString() || ''}
onChange={(e) => setValue('year', parseInt(e.target.value) || currentYear)}
/>
</FormField>
{/* Use Type */}
<FormField
label="نوع الاستخدام"
required
error={combinedErrors.useType}
htmlFor="useType"
>
<Select
id="useType"
name="useType"
placeholder="اختر نوع الاستخدام"
disabled={isLoading}
options={USE_TYPES.map(useType => ({
value: useType.value,
label: useType.label,
}))}
{...getFieldProps('useType')}
/>
</FormField>
</FormGrid>
</FormSection>
<FormSection
title="المحرك والناقل"
description="مواصفات المحرك وناقل الحركة"
>
<FormGrid columns={2}>
{/* Transmission */}
<FormField
label="ناقل الحركة"
required
error={combinedErrors.transmission}
htmlFor="transmission"
>
<Select
id="transmission"
name="transmission"
placeholder="اختر ناقل الحركة"
disabled={isLoading}
options={TRANSMISSION_TYPES.map(transmission => ({
value: transmission.value,
label: transmission.label,
}))}
{...getFieldProps('transmission')}
/>
</FormField>
{/* Fuel */}
<FormField
label="نوع الوقود"
required
error={combinedErrors.fuel}
htmlFor="fuel"
>
<Select
id="fuel"
name="fuel"
placeholder="اختر نوع الوقود"
disabled={isLoading}
options={FUEL_TYPES.map(fuel => ({
value: fuel.value,
label: fuel.label,
}))}
{...getFieldProps('fuel')}
/>
</FormField>
{/* Cylinders */}
<FormField
label="عدد الأسطوانات"
error={combinedErrors.cylinders}
htmlFor="cylinders"
helperText="عدد الأسطوانات اختياري"
>
<Input
id="cylinders"
name="cylinders"
type="number"
min="1"
max={VALIDATION.MAX_CYLINDERS}
placeholder="عدد الأسطوانات (اختياري)"
disabled={isLoading}
value={values.cylinders?.toString() || ''}
onChange={(e) => setValue('cylinders', e.target.value ? parseInt(e.target.value) : null)}
/>
</FormField>
{/* Engine Displacement */}
<FormField
label="سعة المحرك (لتر)"
error={combinedErrors.engineDisplacement}
htmlFor="engineDisplacement"
helperText="سعة المحرك اختيارية"
>
<Input
id="engineDisplacement"
name="engineDisplacement"
type="number"
step="0.1"
min="0.1"
max={VALIDATION.MAX_ENGINE_DISPLACEMENT}
placeholder="سعة المحرك (اختياري)"
disabled={isLoading}
value={values.engineDisplacement?.toString() || ''}
onChange={(e) => setValue('engineDisplacement', e.target.value ? parseFloat(e.target.value) : null)}
/>
</FormField>
</FormGrid>
</FormSection>
<FormActions>
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isLoading}
>
إلغاء
</Button>
<Button
type="submit"
disabled={isLoading || !isValid || !values.plateNumber?.trim() || !values.ownerId}
loading={isLoading}
>
{isEditing ? "تحديث المركبة" : "إنشاء المركبة"}
</Button>
</FormActions>
</Form>
);
}

View File

@@ -0,0 +1,41 @@
import { ReactNode } from 'react';
import { getResponsiveClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface ContainerProps {
children: ReactNode;
className?: string;
config?: Partial<LayoutConfig>;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
padding?: boolean;
}
export function Container({
children,
className = '',
config = {},
maxWidth = 'full',
padding = true
}: ContainerProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const classes = getResponsiveClasses(layoutConfig);
const maxWidthClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
full: 'max-w-full',
};
const paddingClass = padding ? 'px-4 sm:px-6 lg:px-8' : '';
return (
<div
className={`${classes.container} ${maxWidthClasses[maxWidth]} ${paddingClass} ${className}`}
dir={layoutConfig.direction}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,170 @@
import { ReactNode, useState, useEffect } from 'react';
import { Form } from '@remix-run/react';
import { Sidebar } from './Sidebar';
import { Container } from './Container';
import { Flex } from './Flex';
import { Text, Button } from '../ui';
interface DashboardLayoutProps {
children: ReactNode;
user: {
id: number;
name: string;
authLevel: number;
};
}
export function DashboardLayout({ children, user }: DashboardLayoutProps) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
// Handle responsive behavior
useEffect(() => {
const checkMobile = () => {
const mobile = window.innerWidth < 768;
setIsMobile(mobile);
// Auto-collapse sidebar on mobile
if (mobile) {
setSidebarCollapsed(true);
}
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// Load sidebar state from localStorage
useEffect(() => {
const savedState = localStorage.getItem('sidebarCollapsed');
if (savedState !== null && !isMobile) {
setSidebarCollapsed(JSON.parse(savedState));
}
}, [isMobile]);
// Save sidebar state to localStorage
const handleSidebarToggle = () => {
const newState = !sidebarCollapsed;
setSidebarCollapsed(newState);
if (!isMobile) {
localStorage.setItem('sidebarCollapsed', JSON.stringify(newState));
}
};
const handleMobileMenuClose = () => {
setMobileMenuOpen(false);
};
const getAuthLevelText = (authLevel: number) => {
switch (authLevel) {
case 1:
return "مدير عام";
case 2:
return "مدير";
case 3:
return "مستخدم";
default:
return "غير محدد";
}
};
return (
<div className="min-h-screen bg-gray-50 flex" dir="rtl">
{/* Sidebar */}
<Sidebar
isCollapsed={sidebarCollapsed}
onToggle={handleSidebarToggle}
isMobile={isMobile}
isOpen={mobileMenuOpen}
onClose={handleMobileMenuClose}
userAuthLevel={user.authLevel}
/>
{/* Main Content */}
<div className={`
flex-1 min-h-screen transition-all duration-300 ease-in-out
${!isMobile ? (sidebarCollapsed ? 'mr-16' : 'mr-64') : 'mr-0'}
`}>
{/* Header */}
<div className="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-10">
<div className="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-4">
{/* Mobile menu button and title */}
<div className="flex items-center gap-4">
{isMobile && (
<button
onClick={() => setMobileMenuOpen(true)}
className="p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<svg
className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
)}
{/* Page title - only show on mobile when sidebar is closed */}
{isMobile && (
<h1 className="text-lg font-semibold text-gray-900">
لوحة التحكم
</h1>
)}
</div>
{/* User info and actions */}
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-sm text-gray-600">
مرحباً، <span className="font-medium text-gray-900">{user.name}</span>
</div>
<div className="text-xs text-gray-500">
{getAuthLevelText(user.authLevel)}
</div>
</div>
<Form action="/logout" method="post">
<button
type="submit"
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-150"
>
<svg
className="h-4 w-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
خروج
</button>
</Form>
</div>
</div>
</div>
</div>
{/* Page Content */}
<main className="flex-1 p-6">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { ReactNode } from 'react';
import { getResponsiveClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface FlexProps {
children: ReactNode;
className?: string;
config?: Partial<LayoutConfig>;
direction?: 'row' | 'col' | 'row-reverse' | 'col-reverse';
align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline';
justify?: 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
wrap?: boolean;
gap?: 'sm' | 'md' | 'lg' | 'xl';
responsive?: {
sm?: Partial<Pick<FlexProps, 'direction' | 'align' | 'justify'>>;
md?: Partial<Pick<FlexProps, 'direction' | 'align' | 'justify'>>;
lg?: Partial<Pick<FlexProps, 'direction' | 'align' | 'justify'>>;
xl?: Partial<Pick<FlexProps, 'direction' | 'align' | 'justify'>>;
};
}
export function Flex({
children,
className = '',
config = {},
direction = 'row',
align = 'start',
justify = 'start',
wrap = false,
gap = 'md',
responsive = {}
}: FlexProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const classes = getResponsiveClasses(layoutConfig);
const directionClasses = {
row: layoutConfig.direction === 'rtl' ? 'flex-row-reverse' : 'flex-row',
col: 'flex-col',
'row-reverse': layoutConfig.direction === 'rtl' ? 'flex-row' : 'flex-row-reverse',
'col-reverse': 'flex-col-reverse',
};
const alignClasses = {
start: 'items-start',
center: 'items-center',
end: 'items-end',
stretch: 'items-stretch',
baseline: 'items-baseline',
};
const justifyClasses = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between',
around: 'justify-around',
evenly: 'justify-evenly',
};
const gapClasses = {
sm: 'gap-2',
md: 'gap-4',
lg: 'gap-6',
xl: 'gap-8',
};
const wrapClass = wrap ? 'flex-wrap' : '';
// Build responsive classes
const responsiveClasses = Object.entries(responsive)
.map(([breakpoint, props]) => {
const responsiveClassList = [];
if (props.direction) {
const responsiveDirection = props.direction === 'row' && layoutConfig.direction === 'rtl'
? 'flex-row-reverse'
: props.direction === 'row-reverse' && layoutConfig.direction === 'rtl'
? 'flex-row'
: directionClasses[props.direction];
responsiveClassList.push(`${breakpoint}:${responsiveDirection}`);
}
if (props.align) {
responsiveClassList.push(`${breakpoint}:${alignClasses[props.align]}`);
}
if (props.justify) {
responsiveClassList.push(`${breakpoint}:${justifyClasses[props.justify]}`);
}
return responsiveClassList.join(' ');
})
.join(' ');
return (
<div
className={`flex ${directionClasses[direction]} ${alignClasses[align]} ${justifyClasses[justify]} ${gapClasses[gap]} ${wrapClass} ${responsiveClasses} ${className}`}
dir={layoutConfig.direction}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { ReactNode } from 'react';
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface GridProps {
children: ReactNode;
className?: string;
config?: Partial<LayoutConfig>;
cols?: 1 | 2 | 3 | 4 | 6 | 12;
gap?: 'sm' | 'md' | 'lg' | 'xl';
responsive?: {
sm?: 1 | 2 | 3 | 4 | 6 | 12;
md?: 1 | 2 | 3 | 4 | 6 | 12;
lg?: 1 | 2 | 3 | 4 | 6 | 12;
xl?: 1 | 2 | 3 | 4 | 6 | 12;
};
}
export function Grid({
children,
className = '',
config = {},
cols = 1,
gap = 'md',
responsive = {}
}: GridProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const colsClasses = {
1: 'grid-cols-1',
2: 'grid-cols-2',
3: 'grid-cols-3',
4: 'grid-cols-4',
6: 'grid-cols-6',
12: 'grid-cols-12',
};
const gapClasses = {
sm: 'gap-2',
md: 'gap-4',
lg: 'gap-6',
xl: 'gap-8',
};
const responsiveClasses = Object.entries(responsive)
.map(([breakpoint, cols]) => `${breakpoint}:${colsClasses[cols]}`)
.join(' ');
return (
<div
className={`grid-rtl ${colsClasses[cols]} ${gapClasses[gap]} ${responsiveClasses} ${className}`}
dir={layoutConfig.direction}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,257 @@
import { ReactNode, useState, useEffect } from 'react';
import { Link, useLocation } from '@remix-run/react';
import { getResponsiveClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface SidebarProps {
isCollapsed: boolean;
onToggle: () => void;
isMobile: boolean;
isOpen: boolean;
onClose: () => void;
userAuthLevel: number;
}
interface NavigationItem {
name: string;
href: string;
icon: ReactNode;
authLevel?: number; // Minimum auth level required
}
const navigationItems: NavigationItem[] = [
{
name: 'لوحة التحكم',
href: '/dashboard',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5a2 2 0 012-2h4a2 2 0 012 2v6H8V5z" />
</svg>
),
},
{
name: 'العملاء',
href: '/customers',
icon: (
<svg className="w-5 h-5" 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>
),
},
{
name: 'المركبات',
href: '/vehicles',
icon: (
<svg className="w-5 h-5" 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>
),
},
{
name: 'زيارات الصيانة',
href: '/maintenance-visits',
icon: (
<svg className="w-5 h-5" 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>
),
},
{
name: 'المصروفات',
href: '/expenses',
icon: (
<svg className="w-5 h-5" 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>
),
authLevel: 2, // Admin and above
},
{
name: 'التقارير المالية',
href: '/financial-reports',
icon: (
<svg className="w-5 h-5" 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>
),
authLevel: 2, // Admin and above
},
{
name: 'إدارة المستخدمين',
href: '/users',
icon: (
<svg className="w-5 h-5" 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>
),
authLevel: 2, // Admin and above
},
{
name: 'إعدادات النظام',
href: '/settings',
icon: (
<svg className="w-5 h-5" 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>
),
authLevel: 2, // Admin and above
},
];
export function Sidebar({ isCollapsed, onToggle, isMobile, isOpen, onClose, userAuthLevel }: SidebarProps) {
const location = useLocation();
// Filter navigation items based on user auth level
const filteredNavItems = navigationItems.filter(item =>
!item.authLevel || userAuthLevel <= item.authLevel
);
// Close sidebar on route change for mobile
useEffect(() => {
if (isMobile && isOpen) {
onClose();
}
}, [location.pathname, isMobile, isOpen, onClose]);
if (isMobile) {
return (
<>
{/* Mobile overlay */}
{isOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity duration-300"
onClick={onClose}
/>
)}
{/* Mobile Sidebar */}
<div className={`
fixed top-0 right-0 h-full w-64 bg-white shadow-lg z-50 transform transition-transform duration-300 ease-in-out
${isOpen ? 'translate-x-0' : 'translate-x-full'}
`} dir="rtl">
{/* Mobile Header */}
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
<div className="flex items-center">
<div className="h-8 w-8 bg-blue-600 rounded-full flex items-center justify-center">
<svg className="h-5 w-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h1 className="ml-3 text-lg font-semibold text-gray-900">نظام الصيانة</h1>
</div>
<button
onClick={onClose}
className="p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100"
>
<svg className="h-5 w-5" 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>
{/* Mobile Navigation */}
<nav className="mt-5 px-2 flex-1 overflow-y-auto">
<div className="space-y-1">
{filteredNavItems.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
className={`
group flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-150 relative
${isActive ? 'bg-blue-100 text-blue-900' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}
`}
>
{isActive && <div className="absolute right-0 top-0 bottom-0 w-1 bg-blue-600 rounded-l-md"></div>}
<div className={`flex-shrink-0 ${isActive ? 'text-blue-600' : 'text-gray-400 group-hover:text-gray-500'}`}>
{item.icon}
</div>
<span className="ml-3">{item.name}</span>
</Link>
);
})}
</div>
</nav>
</div>
</>
);
}
// Desktop Sidebar
return (
<div className={`
fixed top-0 right-0 h-full bg-white shadow-lg z-40 transition-all duration-300 ease-in-out
${isCollapsed ? 'w-16' : 'w-64'}
`} dir="rtl">
{/* Desktop Header */}
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
<div className="flex items-center flex-1">
<div className="h-8 w-8 bg-blue-600 rounded-full flex items-center justify-center">
<svg className="h-5 w-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
{!isCollapsed && (
<h1 className="ml-3 text-lg font-semibold text-gray-900">نظام الصيانة</h1>
)}
</div>
<button
onClick={onToggle}
className="p-1 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<svg
className={`h-4 w-4 transform transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
{/* Desktop Navigation */}
<nav className="mt-5 px-2 flex-1 overflow-y-auto">
<div className="space-y-1">
{filteredNavItems.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
className={`
group flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-150 relative
${isActive ? 'bg-blue-100 text-blue-900' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}
${isCollapsed ? 'justify-center' : ''}
`}
title={isCollapsed ? item.name : undefined}
>
{isActive && <div className="absolute right-0 top-0 bottom-0 w-1 bg-blue-600 rounded-l-md"></div>}
<div className={`flex-shrink-0 ${isActive ? 'text-blue-600' : 'text-gray-400 group-hover:text-gray-500'}`}>
{item.icon}
</div>
{!isCollapsed && (
<span className="ml-3 truncate">{item.name}</span>
)}
</Link>
);
})}
</div>
</nav>
{/* Desktop Footer */}
{!isCollapsed && (
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
<div className="text-xs text-gray-500 text-center">
نظام إدارة صيانة السيارات
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,5 @@
export { Container } from './Container';
export { Grid } from './Grid';
export { Flex } from './Flex';
export { Sidebar } from './Sidebar';
export { DashboardLayout } from './DashboardLayout';

View File

@@ -0,0 +1,311 @@
import { Link } from "@remix-run/react";
import { useSettings } from "~/contexts/SettingsContext";
import type { MaintenanceVisitWithRelations } from "~/types/database";
interface MaintenanceVisitDetailsViewProps {
visit: MaintenanceVisitWithRelations;
}
export function MaintenanceVisitDetailsView({ visit }: MaintenanceVisitDetailsViewProps) {
const { formatCurrency, formatDate, formatNumber } = useSettings();
const getPaymentStatusColor = (status: string) => {
switch (status) {
case 'paid':
return 'bg-green-100 text-green-800 border-green-200';
case 'pending':
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'partial':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'cancelled':
return 'bg-red-100 text-red-800 border-red-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const getPaymentStatusLabel = (status: string) => {
switch (status) {
case 'paid': return 'مدفوع';
case 'pending': return 'معلق';
case 'partial': return 'مدفوع جزئياً';
case 'cancelled': return 'ملغي';
default: return status;
}
};
const getDelayLabel = (months: number) => {
const delayOptions = [
{ value: 1, label: 'شهر واحد' },
{ value: 2, label: 'شهرين' },
{ value: 3, label: '3 أشهر' },
{ value: 4, label: '4 أشهر' },
{ value: 6, label: '6 أشهر' },
{ value: 12, label: 'سنة واحدة' },
];
const option = delayOptions.find(opt => opt.value === months);
return option ? option.label : `${months} أشهر`;
};
return (
<div className="space-y-8">
{/* Header Section */}
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-100">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center">
<span className="text-3xl ml-3">🔧</span>
<div>
<h2 className="text-2xl font-bold text-gray-900">
زيارة صيانة #{visit.id}
</h2>
<p className="text-gray-600 mt-1">
{formatDate(visit.visitDate)}
</p>
</div>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-blue-600 mb-1">
{formatCurrency(visit.cost)}
</div>
<span className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full border ${getPaymentStatusColor(visit.paymentStatus)}`}>
{getPaymentStatusLabel(visit.paymentStatus)}
</span>
</div>
</div>
</div>
{/* Main Details Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Visit Information */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<span className="text-gray-600 text-xl ml-2">📋</span>
تفاصيل الزيارة
</h3>
</div>
<div className="p-6 space-y-4">
<div className="bg-blue-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-3">أعمال الصيانة المنجزة</label>
<div className="space-y-3">
{(() => {
try {
const jobs = JSON.parse(visit.maintenanceJobs);
return jobs.map((job: any, index: number) => (
<div key={index} className="bg-white p-3 rounded-lg border border-blue-200">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="font-semibold text-gray-900 mb-1">{job.job}</p>
{job.notes && (
<p className="text-sm text-gray-600">{job.notes}</p>
)}
</div>
<div className="flex flex-col items-end gap-1">
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded-full">
#{index + 1}
</span>
{job.cost !== undefined && (
<span className="text-sm font-bold text-gray-700">
{formatCurrency(job.cost)}
</span>
)}
</div>
</div>
</div>
));
} catch {
return (
<div className="bg-white p-3 rounded-lg border border-blue-200">
<p className="text-gray-900">لا توجد تفاصيل أعمال الصيانة</p>
</div>
);
}
})()}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="bg-gray-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">عداد الكيلومترات</label>
<p className="text-gray-900 font-mono text-lg">
{formatNumber(visit.kilometers)} كم
</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">الزيارة التالية بعد</label>
<p className="text-gray-900 font-medium">
{getDelayLabel(visit.nextVisitDelay)}
</p>
</div>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-2">وصف الأعمال المنجزة</label>
<div className="bg-white rounded-lg p-4 border">
<p className="text-gray-900 whitespace-pre-wrap leading-relaxed">
{visit.description}
</p>
</div>
</div>
</div>
</div>
{/* Vehicle & Customer Information */}
<div className="space-y-6">
{/* Vehicle Info */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<span className="text-gray-600 text-xl ml-2">🚗</span>
معلومات المركبة
</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-2 gap-4">
<div className="bg-green-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">رقم اللوحة</label>
<p className="text-xl font-bold text-gray-900 font-mono">
{visit.vehicle.plateNumber}
</p>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">السنة</label>
<p className="text-lg font-semibold text-gray-900">
{visit.vehicle.year}
</p>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">الشركة المصنعة</label>
<p className="text-gray-900 font-medium">
{visit.vehicle.manufacturer}
</p>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">الموديل</label>
<p className="text-gray-900 font-medium">
{visit.vehicle.model}
</p>
</div>
</div>
</div>
</div>
{/* Customer Info */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<span className="text-gray-600 text-xl ml-2">👤</span>
معلومات العميل
</h3>
</div>
<div className="p-6">
<div className="space-y-4">
<div className="bg-blue-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">اسم العميل</label>
<p className="text-lg font-semibold text-gray-900">
{visit.customer.name}
</p>
</div>
{(visit.customer.phone || visit.customer.email) && (
<div className="grid grid-cols-1 gap-4">
{visit.customer.phone && (
<div className="bg-blue-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">رقم الهاتف</label>
<a
href={`tel:${visit.customer.phone}`}
className="text-blue-600 hover:text-blue-800 font-mono font-medium"
dir="ltr"
>
📞 {visit.customer.phone}
</a>
</div>
)}
{visit.customer.email && (
<div className="bg-blue-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">البريد الإلكتروني</label>
<a
href={`mailto:${visit.customer.email}`}
className="text-blue-600 hover:text-blue-800 font-medium"
dir="ltr"
>
{visit.customer.email}
</a>
</div>
)}
</div>
)}
{visit.customer.address && (
<div className="bg-blue-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">العنوان</label>
<p className="text-gray-900">
{visit.customer.address}
</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
{/* Income Information */}
{visit.income && visit.income.length > 0 && (
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<span className="text-gray-600 text-xl ml-2">💰</span>
سجل الدخل
</h3>
</div>
<div className="p-6">
<div className="space-y-3">
{visit.income.map((income) => (
<div key={income.id} className="flex justify-between items-center bg-green-50 p-4 rounded-lg">
<div>
<p className="text-sm text-gray-600">تاريخ الدخل</p>
<p className="font-medium text-gray-900">
{formatDate(income.incomeDate)}
</p>
</div>
<div className="text-right">
<p className="text-sm text-gray-600">المبلغ</p>
<p className="text-xl font-bold text-green-600 font-mono">
{formatCurrency(income.amount)}
</p>
</div>
</div>
))}
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex flex-wrap gap-3 pt-6 border-t border-gray-200">
<Link
to={`/vehicles?search=${encodeURIComponent(visit.vehicle.plateNumber)}`}
className="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors"
>
<span className="ml-2">🚗</span>
عرض تفاصيل المركبة
</Link>
<Link
to={`/customers?search=${encodeURIComponent(visit.customer.name)}`}
className="inline-flex items-center px-4 py-2 text-sm font-medium text-green-600 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
>
<span className="ml-2">👤</span>
عرض تفاصيل العميل
</Link>
<Link
to={`/maintenance-visits?vehicleId=${visit.vehicle.id}`}
className="inline-flex items-center px-4 py-2 text-sm font-medium text-purple-600 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
>
<span className="ml-2">📋</span>
جميع زيارات هذه المركبة
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,564 @@
import { useState, useEffect } from "react";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { Button, Input, Select, Text, Card, CardHeader, CardBody, MultiSelect } from "~/components/ui";
import { AutocompleteInput } from "~/components/ui/AutocompleteInput";
import { useSettings } from "~/contexts/SettingsContext";
import type { MaintenanceVisitWithRelations, Customer, Vehicle, MaintenanceType, MaintenanceJob } from "~/types/database";
import { PAYMENT_STATUS_NAMES, VISIT_DELAY_OPTIONS } from "~/lib/constants";
interface MaintenanceVisitFormProps {
visit?: MaintenanceVisitWithRelations;
customers: Customer[];
vehicles: Vehicle[];
maintenanceTypes: { id: number; name: string; }[];
onCancel?: () => void;
}
export function MaintenanceVisitForm({
visit,
customers,
vehicles,
maintenanceTypes,
onCancel
}: MaintenanceVisitFormProps) {
const actionData = useActionData<any>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const { settings } = useSettings();
// Form state
const [plateNumberInput, setPlateNumberInput] = useState<string>(
visit?.vehicle?.plateNumber || ""
);
const [selectedCustomerId, setSelectedCustomerId] = useState<string>(
visit?.customerId?.toString() || ""
);
const [selectedVehicleId, setSelectedVehicleId] = useState<string>(
visit?.vehicleId?.toString() || ""
);
const [filteredVehicles, setFilteredVehicles] = useState<Vehicle[]>(vehicles);
// Maintenance jobs state (with costs)
const [maintenanceJobs, setMaintenanceJobs] = useState<MaintenanceJob[]>(() => {
if (visit?.maintenanceJobs) {
try {
const jobs = JSON.parse(visit.maintenanceJobs);
return jobs;
} catch {
return [];
}
}
return [];
});
// Current maintenance type being added
const [currentTypeId, setCurrentTypeId] = useState<string>("");
const [currentCost, setCurrentCost] = useState<string>("");
// Create autocomplete options for plate numbers
const plateNumberOptions = vehicles.map(vehicle => {
const customer = customers.find(c => c.id === vehicle.ownerId);
return {
value: vehicle.plateNumber,
label: `${vehicle.plateNumber} - ${vehicle.manufacturer} ${vehicle.model} (${customer?.name || 'غير محدد'})`,
data: {
vehicle,
customer
}
};
});
// Handle plate number selection
const handlePlateNumberSelect = (option: any) => {
const { vehicle, customer } = option.data;
setPlateNumberInput(vehicle.plateNumber);
setSelectedCustomerId(customer?.id?.toString() || "");
setSelectedVehicleId(vehicle.id.toString());
};
// Reset form state when visit prop changes (switching between create/edit modes)
useEffect(() => {
if (visit) {
// Editing mode - populate with visit data
setPlateNumberInput(visit.vehicle?.plateNumber || "");
setSelectedCustomerId(visit.customerId?.toString() || "");
setSelectedVehicleId(visit.vehicleId?.toString() || "");
// Parse maintenance jobs from JSON
try {
const jobs = JSON.parse(visit.maintenanceJobs);
setMaintenanceJobs(jobs);
} catch {
setMaintenanceJobs([]);
}
} else {
// Create mode - reset to empty state
setPlateNumberInput("");
setSelectedCustomerId("");
setSelectedVehicleId("");
setMaintenanceJobs([]);
setCurrentTypeId("");
setCurrentCost("");
}
}, [visit]);
// Filter vehicles based on selected customer
useEffect(() => {
if (selectedCustomerId) {
const customerId = parseInt(selectedCustomerId);
const customerVehicles = vehicles.filter(v => v.ownerId === customerId);
setFilteredVehicles(customerVehicles);
// Reset vehicle selection if current vehicle doesn't belong to selected customer
if (selectedVehicleId) {
const vehicleId = parseInt(selectedVehicleId);
const vehicleBelongsToCustomer = customerVehicles.some(v => v.id === vehicleId);
if (!vehicleBelongsToCustomer) {
setSelectedVehicleId("");
}
}
} else {
setFilteredVehicles(vehicles);
}
}, [selectedCustomerId, vehicles, selectedVehicleId]);
// Format date for input
const formatDateForInput = (date: Date | string | null) => {
if (!date) return "";
const d = new Date(date);
return d.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM format
};
// Calculate total cost from maintenance jobs
const totalCost = maintenanceJobs.reduce((sum, job) => sum + job.cost, 0);
// Add maintenance job
const handleAddMaintenanceJob = () => {
if (!currentTypeId || !currentCost) return;
const typeIdNum = parseInt(currentTypeId);
const costNum = parseFloat(currentCost);
if (isNaN(typeIdNum) || isNaN(costNum) || costNum <= 0) return;
// Check if type already exists
if (maintenanceJobs.some(job => job.typeId === typeIdNum)) {
alert("هذا النوع من الصيانة موجود بالفعل");
return;
}
const type = maintenanceTypes.find(t => t.id === typeIdNum);
if (!type) return;
const newJob: MaintenanceJob = {
typeId: typeIdNum,
job: type.name,
cost: costNum,
notes: ''
};
setMaintenanceJobs([...maintenanceJobs, newJob]);
setCurrentTypeId("");
setCurrentCost("");
};
// Remove maintenance job
const handleRemoveMaintenanceJob = (typeId: number) => {
setMaintenanceJobs(maintenanceJobs.filter(job => job.typeId !== typeId));
};
return (
<Card>
<CardHeader>
<Text weight="medium" size="lg">
{visit ? "تعديل زيارة الصيانة" : "إضافة زيارة صيانة جديدة"}
</Text>
</CardHeader>
<CardBody>
<Form method="post" className="space-y-6">
{visit && (
<input type="hidden" name="id" value={visit.id} />
)}
{/* Plate Number Autocomplete - Only show for new visits */}
{!visit && (
<div>
<AutocompleteInput
label="رقم اللوحة"
placeholder="ابدأ بكتابة رقم اللوحة..."
value={plateNumberInput}
onChange={setPlateNumberInput}
onSelect={handlePlateNumberSelect}
options={plateNumberOptions}
required
/>
<Text size="sm" color="secondary" className="mt-1">
ابدأ بكتابة رقم اللوحة لاختيار المركبة والعميل تلقائياً
</Text>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Customer Selection */}
<div>
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
العميل
<span className="text-red-500 mr-1">*</span>
</label>
<select
name="customerId"
value={selectedCustomerId}
onChange={(e) => setSelectedCustomerId(e.target.value)}
required
disabled={false}
className={`w-full px-3 py-2 border rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 appearance-none bg-white ${actionData?.errors?.customerId
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300'
}`}
>
<option value="">اختر العميل</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id.toString()}>
{customer.name}{customer.phone ? ` - ${customer.phone}` : ''}
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none mt-7">
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</div>
</div>
{actionData?.errors?.customerId && (
<Text size="sm" color="error" className="mt-1">
{actionData.errors.customerId}
</Text>
)}
{!visit && plateNumberInput && selectedCustomerId && (
<Text size="sm" color="success" className="mt-1">
تم اختيار العميل تلقائياً من رقم اللوحة
</Text>
)}
</div>
{/* Vehicle Selection */}
<div>
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
المركبة
<span className="text-red-500 mr-1">*</span>
</label>
<select
name="vehicleId"
value={selectedVehicleId}
onChange={(e) => setSelectedVehicleId(e.target.value)}
required
disabled={false}
className={`w-full px-3 py-2 border rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 appearance-none bg-white ${actionData?.errors?.vehicleId
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300'
}`}
>
<option value="">اختر المركبة</option>
{filteredVehicles.map((vehicle) => (
<option key={vehicle.id} value={vehicle.id.toString()}>
{vehicle.plateNumber} - {vehicle.manufacturer} {vehicle.model} ({vehicle.year})
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none mt-7">
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</div>
</div>
{actionData?.errors?.vehicleId && (
<Text size="sm" color="error" className="mt-1">
{actionData.errors.vehicleId}
</Text>
)}
{!selectedCustomerId && !plateNumberInput && (
<Text size="sm" color="secondary" className="mt-1">
يرجى اختيار العميل أولاً أو البحث برقم اللوحة
</Text>
)}
{!visit && plateNumberInput && selectedVehicleId && (
<Text size="sm" color="success" className="mt-1">
تم اختيار المركبة تلقائياً من رقم اللوحة
</Text>
)}
</div>
</div>
{/* Maintenance Types Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
أنواع الصيانة
<span className="text-red-500 mr-1">*</span>
</label>
{/* Add Maintenance Type Form */}
<div className="flex gap-2 mb-3">
<div className="flex-1">
<select
value={currentTypeId}
onChange={(e) => setCurrentTypeId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">اختر نوع الصيانة</option>
{maintenanceTypes
.filter(type => !maintenanceJobs.some(job => job.typeId === type.id))
.map((type) => (
<option key={type.id} value={type.id.toString()}>
{type.name}
</option>
))}
</select>
</div>
<div className="w-32">
<input
type="number"
value={currentCost}
onChange={(e) => setCurrentCost(e.target.value)}
placeholder="التكلفة"
step="0.01"
min="0"
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<Button
type="button"
onClick={handleAddMaintenanceJob}
disabled={!currentTypeId || !currentCost}
size="sm"
>
إضافة
</Button>
</div>
{/* List of Added Maintenance Types */}
{maintenanceJobs.length > 0 && (
<div className="space-y-2 mb-3">
{maintenanceJobs.map((job) => (
<div
key={job.typeId}
className="flex items-center justify-between p-3 bg-gray-50 rounded-md border border-gray-200"
>
<div className="flex-1">
<span className="font-medium">{job.job}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-gray-700">{job.cost.toFixed(2)} {settings.currency}</span>
<button
type="button"
onClick={() => handleRemoveMaintenanceJob(job.typeId)}
className="text-red-600 hover:text-red-800"
>
<svg className="h-5 w-5" 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>
))}
</div>
)}
{maintenanceJobs.length === 0 && (
<Text size="sm" color="secondary" className="mb-3">
لم يتم إضافة أي نوع صيانة بعد
</Text>
)}
{/* Hidden input to pass maintenance jobs data */}
<input
type="hidden"
name="maintenanceJobsData"
value={JSON.stringify(maintenanceJobs)}
/>
{actionData?.errors?.maintenanceJobs && (
<Text size="sm" color="error" className="mt-1">
{actionData.errors.maintenanceJobs}
</Text>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-1 gap-6">
{/* Payment Status */}
<div>
<Select
name="paymentStatus"
label="حالة الدفع"
defaultValue={visit?.paymentStatus || "pending"}
error={actionData?.errors?.paymentStatus}
required
options={Object.entries(PAYMENT_STATUS_NAMES).map(([value, label]) => ({
value: value,
label: label
}))}
/>
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
وصف الصيانة
<span className="text-red-500 mr-1">*</span>
</label>
<textarea
name="description"
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
defaultValue={visit?.description || ""}
placeholder="اكتب وصف تفصيلي للأعمال المنجزة..."
required
/>
{actionData?.errors?.description && (
<Text size="sm" color="error" className="mt-1">
{actionData.errors.description}
</Text>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Cost */}
<div>
<Input
type="number"
name="cost"
label={`التكلفة الإجمالية (${settings.currency})`}
value={totalCost.toFixed(2)}
error={actionData?.errors?.cost}
step="0.01"
min="0"
readOnly
className="bg-gray-50"
/>
<Text size="sm" color="secondary" className="mt-1">
يتم حساب التكلفة تلقائياً من أنواع الصيانة المضافة
</Text>
</div>
{/* Kilometers */}
<div>
<Input
type="number"
name="kilometers"
label="عدد الكيلومترات"
defaultValue={visit?.kilometers?.toString() || ""}
error={actionData?.errors?.kilometers}
min="0"
required
/>
</div>
{/* Next Visit Delay */}
<div>
<Select
name="nextVisitDelay"
label="الزيارة التالية بعد"
defaultValue={visit?.nextVisitDelay?.toString() || "3"}
error={actionData?.errors?.nextVisitDelay}
required
options={VISIT_DELAY_OPTIONS.map((option) => ({
value: option.value.toString(),
label: option.label
}))}
/>
</div>
</div>
{/* Visit Date */}
<div>
<Input
type="datetime-local"
name="visitDate"
label="تاريخ ووقت الزيارة"
defaultValue={formatDateForInput(visit?.visitDate || new Date())}
error={actionData?.errors?.visitDate}
required
/>
</div>
{/* Action Buttons */}
{/* Debug Info */}
{!visit && (
<div className="bg-gray-50 p-3 rounded text-xs">
<strong>Debug Info:</strong><br />
Customer ID: {selectedCustomerId || "Not selected"}<br />
Vehicle ID: {selectedVehicleId || "Not selected"}<br />
Plate Number: {plateNumberInput || "Not entered"}<br />
Selected Maintenance Types: {maintenanceJobs.length} types<br />
Types: {maintenanceJobs.map(j => j.job).join(', ') || "None selected"}
</div>
)}
<div className="flex justify-end gap-3 pt-4">
{onCancel && (
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={isSubmitting}
>
إلغاء
</Button>
)}
<Button
type="submit"
name="intent"
value={visit ? "update" : "create"}
disabled={isSubmitting}
onClick={(e) => {
// Client-side validation before submission
if (!visit) {
const form = e.currentTarget.form;
if (!form) return;
const formData = new FormData(form);
const customerId = formData.get("customerId") as string;
const vehicleId = formData.get("vehicleId") as string;
const description = formData.get("description") as string;
const cost = formData.get("cost") as string;
const kilometers = formData.get("kilometers") as string;
const hasValidCustomer = customerId && customerId !== "";
const hasValidVehicle = vehicleId && vehicleId !== "";
const hasValidJobs = maintenanceJobs.length > 0;
const hasValidDescription = description && description.trim() !== "";
const hasValidCost = cost && cost.trim() !== "" && parseFloat(cost) > 0;
const hasValidKilometers = kilometers && kilometers.trim() !== "" && parseInt(kilometers) >= 0;
const missingFields = [];
if (!hasValidCustomer) missingFields.push("العميل");
if (!hasValidVehicle) missingFields.push("المركبة");
if (!hasValidJobs) missingFields.push("نوع صيانة واحد على الأقل");
if (!hasValidDescription) missingFields.push("وصف الصيانة");
if (!hasValidCost) missingFields.push("التكلفة");
if (!hasValidKilometers) missingFields.push("عدد الكيلومترات");
if (missingFields.length > 0) {
e.preventDefault();
alert(`يرجى ملء الحقول المطلوبة التالية:\n- ${missingFields.join('\n- ')}`);
return;
}
}
}}
>
{isSubmitting
? visit
? "جاري التحديث..."
: "جاري الحفظ..."
: visit
? "تحديث الزيارة"
: "حفظ الزيارة"}
</Button>
</div>
</Form>
</CardBody>
</Card>
);
}

View File

@@ -0,0 +1,462 @@
import { useState, useEffect } from "react";
import { Form, useActionData, useNavigation } from "@remix-run/react";
import { Button, Input, Select, Text, Card, CardHeader, CardBody, MultiSelect } from "~/components/ui";
import { AutocompleteInput } from "~/components/ui/AutocompleteInput";
import type { MaintenanceVisitWithRelations, Customer, Vehicle, MaintenanceType, MaintenanceJob } from "~/types/database";
import { PAYMENT_STATUS_NAMES, VISIT_DELAY_OPTIONS } from "~/lib/constants";
interface MaintenanceVisitFormProps {
visit?: MaintenanceVisitWithRelations;
customers: Customer[];
vehicles: Vehicle[];
maintenanceTypes: { id: number; name: string; }[];
onCancel?: () => void;
}
export function MaintenanceVisitForm({
visit,
customers,
vehicles,
maintenanceTypes,
onCancel
}: MaintenanceVisitFormProps) {
const actionData = useActionData<any>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
// Form state
const [plateNumberInput, setPlateNumberInput] = useState<string>(
visit?.vehicle?.plateNumber || ""
);
const [selectedCustomerId, setSelectedCustomerId] = useState<string>(
visit?.customerId?.toString() || ""
);
const [selectedVehicleId, setSelectedVehicleId] = useState<string>(
visit?.vehicleId?.toString() || ""
);
const [filteredVehicles, setFilteredVehicles] = useState<Vehicle[]>(vehicles);
// Selected maintenance types state
const [selectedMaintenanceTypes, setSelectedMaintenanceTypes] = useState<number[]>(() => {
if (visit?.maintenanceJobs) {
try {
const jobs = JSON.parse(visit.maintenanceJobs);
return jobs.map((job: MaintenanceJob) => job.typeId).filter((id: number) => id > 0);
} catch {
return [];
}
}
return [];
});
// Create autocomplete options for plate numbers
const plateNumberOptions = vehicles.map(vehicle => {
const customer = customers.find(c => c.id === vehicle.ownerId);
return {
value: vehicle.plateNumber,
label: `${vehicle.plateNumber} - ${vehicle.manufacturer} ${vehicle.model} (${customer?.name || 'غير محدد'})`,
data: {
vehicle,
customer
}
};
});
// Handle plate number selection
const handlePlateNumberSelect = (option: any) => {
const { vehicle, customer } = option.data;
setPlateNumberInput(vehicle.plateNumber);
setSelectedCustomerId(customer?.id?.toString() || "");
setSelectedVehicleId(vehicle.id.toString());
};
// Reset form state when visit prop changes (switching between create/edit modes)
useEffect(() => {
if (visit) {
// Editing mode - populate with visit data
setPlateNumberInput(visit.vehicle?.plateNumber || "");
setSelectedCustomerId(visit.customerId?.toString() || "");
setSelectedVehicleId(visit.vehicleId?.toString() || "");
// Parse maintenance jobs from JSON
try {
const jobs = JSON.parse(visit.maintenanceJobs);
const typeIds = jobs.map((job: MaintenanceJob) => job.typeId).filter((id: number) => id > 0);
setSelectedMaintenanceTypes(typeIds);
} catch {
setSelectedMaintenanceTypes([]);
}
} else {
// Create mode - reset to empty state
setPlateNumberInput("");
setSelectedCustomerId("");
setSelectedVehicleId("");
setSelectedMaintenanceTypes([]);
}
}, [visit]);
// Filter vehicles based on selected customer
useEffect(() => {
if (selectedCustomerId) {
const customerId = parseInt(selectedCustomerId);
const customerVehicles = vehicles.filter(v => v.ownerId === customerId);
setFilteredVehicles(customerVehicles);
// Reset vehicle selection if current vehicle doesn't belong to selected customer
if (selectedVehicleId) {
const vehicleId = parseInt(selectedVehicleId);
const vehicleBelongsToCustomer = customerVehicles.some(v => v.id === vehicleId);
if (!vehicleBelongsToCustomer) {
setSelectedVehicleId("");
}
}
} else {
setFilteredVehicles(vehicles);
}
}, [selectedCustomerId, vehicles, selectedVehicleId]);
// Format date for input
const formatDateForInput = (date: Date | string | null) => {
if (!date) return "";
const d = new Date(date);
return d.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM format
};
// Convert selected maintenance types to jobs format for submission
const getMaintenanceJobsForSubmission = () => {
return selectedMaintenanceTypes.map(typeId => {
const type = maintenanceTypes.find(t => t.id === typeId);
return {
typeId,
job: type?.name || '',
cost: 0,
notes: ''
};
});
};
return (
<Card>
<CardHeader>
<Text weight="medium" size="lg">
{visit ? "تعديل زيارة الصيانة" : "إضافة زيارة صيانة جديدة"}
</Text>
</CardHeader>
<CardBody>
<Form method="post" className="space-y-6">
{visit && (
<input type="hidden" name="id" value={visit.id} />
)}
{/* Plate Number Autocomplete - Only show for new visits */}
{!visit && (
<div>
<AutocompleteInput
label="رقم اللوحة"
placeholder="ابدأ بكتابة رقم اللوحة..."
value={plateNumberInput}
onChange={setPlateNumberInput}
onSelect={handlePlateNumberSelect}
options={plateNumberOptions}
required
/>
<Text size="sm" color="secondary" className="mt-1">
ابدأ بكتابة رقم اللوحة لاختيار المركبة والعميل تلقائياً
</Text>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Customer Selection */}
<div>
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
العميل
<span className="text-red-500 mr-1">*</span>
</label>
<select
name="customerId"
value={selectedCustomerId}
onChange={(e) => setSelectedCustomerId(e.target.value)}
required
disabled={false}
className={`w-full px-3 py-2 border rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 appearance-none bg-white ${actionData?.errors?.customerId
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300'
}`}
>
<option value="">اختر العميل</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id.toString()}>
{customer.name}{customer.phone ? ` - ${customer.phone}` : ''}
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none mt-7">
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</div>
</div>
{actionData?.errors?.customerId && (
<Text size="sm" color="error" className="mt-1">
{actionData.errors.customerId}
</Text>
)}
{!visit && plateNumberInput && selectedCustomerId && (
<Text size="sm" color="success" className="mt-1">
تم اختيار العميل تلقائياً من رقم اللوحة
</Text>
)}
</div>
{/* Vehicle Selection */}
<div>
<div className="relative">
<label className="block text-sm font-medium text-gray-700 mb-2">
المركبة
<span className="text-red-500 mr-1">*</span>
</label>
<select
name="vehicleId"
value={selectedVehicleId}
onChange={(e) => setSelectedVehicleId(e.target.value)}
required
disabled={false}
className={`w-full px-3 py-2 border rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 appearance-none bg-white ${actionData?.errors?.vehicleId
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300'
}`}
>
<option value="">اختر المركبة</option>
{filteredVehicles.map((vehicle) => (
<option key={vehicle.id} value={vehicle.id.toString()}>
{vehicle.plateNumber} - {vehicle.manufacturer} {vehicle.model} ({vehicle.year})
</option>
))}
</select>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none mt-7">
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</div>
</div>
{actionData?.errors?.vehicleId && (
<Text size="sm" color="error" className="mt-1">
{actionData.errors.vehicleId}
</Text>
)}
{!selectedCustomerId && !plateNumberInput && (
<Text size="sm" color="secondary" className="mt-1">
يرجى اختيار العميل أولاً أو البحث برقم اللوحة
</Text>
)}
{!visit && plateNumberInput && selectedVehicleId && (
<Text size="sm" color="success" className="mt-1">
تم اختيار المركبة تلقائياً من رقم اللوحة
</Text>
)}
</div>
</div>
{/* Maintenance Types Selection */}
<div>
<MultiSelect
name="maintenanceJobs"
label="أنواع الصيانة"
options={maintenanceTypes.map(type => ({
value: type.id,
label: type.name
}))}
value={selectedMaintenanceTypes}
onChange={setSelectedMaintenanceTypes}
placeholder="اختر أنواع الصيانة المطلوبة..."
error={actionData?.errors?.maintenanceJobs}
required
/>
<Text size="sm" color="secondary" className="mt-1">
يمكنك اختيار أكثر من نوع صيانة واحد
</Text>
{/* Hidden input to pass maintenance jobs data in the expected format */}
<input
type="hidden"
name="maintenanceJobsData"
value={JSON.stringify(getMaintenanceJobsForSubmission())}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-1 gap-6">
{/* Payment Status */}
<div>
<Select
name="paymentStatus"
label="حالة الدفع"
defaultValue={visit?.paymentStatus || "pending"}
error={actionData?.errors?.paymentStatus}
required
options={Object.entries(PAYMENT_STATUS_NAMES).map(([value, label]) => ({
value: value,
label: label
}))}
/>
</div>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
وصف الصيانة
<span className="text-red-500 mr-1">*</span>
</label>
<textarea
name="description"
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
defaultValue={visit?.description || ""}
placeholder="اكتب وصف تفصيلي للأعمال المنجزة..."
required
/>
{actionData?.errors?.description && (
<Text size="sm" color="error" className="mt-1">
{actionData.errors.description}
</Text>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Cost */}
<div>
<Input
type="number"
name="cost"
label="التكلفة (ريال)"
defaultValue={visit?.cost?.toString() || ""}
error={actionData?.errors?.cost}
step="0.01"
min="0"
max="999999.99"
required
/>
</div>
{/* Kilometers */}
<div>
<Input
type="number"
name="kilometers"
label="عدد الكيلومترات"
defaultValue={visit?.kilometers?.toString() || ""}
error={actionData?.errors?.kilometers}
min="0"
required
/>
</div>
{/* Next Visit Delay */}
<div>
<Select
name="nextVisitDelay"
label="الزيارة التالية بعد"
defaultValue={visit?.nextVisitDelay?.toString() || "3"}
error={actionData?.errors?.nextVisitDelay}
required
options={VISIT_DELAY_OPTIONS.map((option) => ({
value: option.value,
label: option.label
}))}
/>
</div>
</div>
{/* Visit Date */}
<div>
<Input
type="datetime-local"
name="visitDate"
label="تاريخ ووقت الزيارة"
defaultValue={formatDateForInput(visit?.visitDate || new Date())}
error={actionData?.errors?.visitDate}
required
/>
</div>
{/* Action Buttons */}
{/* Debug Info */}
{!visit && (
<div className="bg-gray-50 p-3 rounded text-xs">
<strong>Debug Info:</strong><br />
Customer ID: {selectedCustomerId || "Not selected"}<br />
Vehicle ID: {selectedVehicleId || "Not selected"}<br />
Plate Number: {plateNumberInput || "Not entered"}<br />
Selected Maintenance Types: {selectedMaintenanceTypes.length} types<br />
Types: {selectedMaintenanceTypes.join(', ') || "None selected"}
</div>
)}
<div className="flex justify-end gap-3 pt-4">
{onCancel && (
<Button
type="button"
variant="secondary"
onClick={onCancel}
disabled={isSubmitting}
>
إلغاء
</Button>
)}
<Button
type="submit"
name="intent"
value={visit ? "update" : "create"}
disabled={isSubmitting}
onClick={(e) => {
// Client-side validation before submission
if (!visit) {
const form = e.currentTarget.form;
if (!form) return;
const formData = new FormData(form);
const customerId = formData.get("customerId") as string;
const vehicleId = formData.get("vehicleId") as string;
const description = formData.get("description") as string;
const cost = formData.get("cost") as string;
const kilometers = formData.get("kilometers") as string;
const hasValidCustomer = customerId && customerId !== "";
const hasValidVehicle = vehicleId && vehicleId !== "";
const hasValidJobs = selectedMaintenanceTypes.length > 0;
const hasValidDescription = description && description.trim() !== "";
const hasValidCost = cost && cost.trim() !== "" && parseFloat(cost) > 0;
const hasValidKilometers = kilometers && kilometers.trim() !== "" && parseInt(kilometers) >= 0;
const missingFields = [];
if (!hasValidCustomer) missingFields.push("العميل");
if (!hasValidVehicle) missingFields.push("المركبة");
if (!hasValidJobs) missingFields.push("نوع صيانة واحد على الأقل");
if (!hasValidDescription) missingFields.push("وصف الصيانة");
if (!hasValidCost) missingFields.push("التكلفة");
if (!hasValidKilometers) missingFields.push("عدد الكيلومترات");
if (missingFields.length > 0) {
e.preventDefault();
alert(`يرجى ملء الحقول المطلوبة التالية:\n- ${missingFields.join('\n- ')}`);
return;
}
}
}}
>
{isSubmitting
? visit
? "جاري التحديث..."
: "جاري الحفظ..."
: visit
? "تحديث الزيارة"
: "حفظ الزيارة"}
</Button>
</div>
</Form>
</CardBody>
</Card>
);
}

View File

@@ -0,0 +1,253 @@
import { useState } from "react";
import { Link, Form } from "@remix-run/react";
import { Button } from "~/components/ui/Button";
import { Text } from "~/components/ui/Text";
import { DataTable } from "~/components/ui/DataTable";
import type { MaintenanceVisitWithRelations } from "~/types/database";
import { PAYMENT_STATUS_NAMES } from "~/lib/constants";
import { useSettings } from "~/contexts/SettingsContext";
interface MaintenanceVisitListProps {
visits: MaintenanceVisitWithRelations[];
onEdit?: (visit: MaintenanceVisitWithRelations) => void;
onView?: (visit: MaintenanceVisitWithRelations) => void;
}
export function MaintenanceVisitList({
visits,
onEdit,
onView
}: MaintenanceVisitListProps) {
const { formatDate, formatCurrency, formatNumber, formatDateTime } = useSettings();
const [deleteVisitId, setDeleteVisitId] = useState<number | null>(null);
const handleDelete = (visitId: number) => {
setDeleteVisitId(visitId);
};
const confirmDelete = () => {
if (deleteVisitId) {
// Submit delete form
const form = document.createElement('form');
form.method = 'post';
form.style.display = 'none';
const intentInput = document.createElement('input');
intentInput.type = 'hidden';
intentInput.name = 'intent';
intentInput.value = 'delete';
const idInput = document.createElement('input');
idInput.type = 'hidden';
idInput.name = 'id';
idInput.value = deleteVisitId.toString();
form.appendChild(intentInput);
form.appendChild(idInput);
document.body.appendChild(form);
form.submit();
}
};
const getPaymentStatusColor = (status: string) => {
switch (status) {
case 'paid':
return 'text-green-600 bg-green-50';
case 'pending':
return 'text-yellow-600 bg-yellow-50';
case 'partial':
return 'text-blue-600 bg-blue-50';
case 'cancelled':
return 'text-red-600 bg-red-50';
default:
return 'text-gray-600 bg-gray-50';
}
};
const columns = [
{
key: 'visitDate',
header: 'تاريخ الزيارة',
render: (visit: MaintenanceVisitWithRelations) => (
<div>
<Text weight="medium">
{formatDate(visit.visitDate)}
</Text>
<Text size="sm" color="secondary">
{formatDateTime(visit.visitDate).split(' ')[1]}
</Text>
</div>
),
},
{
key: 'vehicle',
header: 'المركبة',
render: (visit: MaintenanceVisitWithRelations) => (
<div>
<Text weight="medium">{visit.vehicle.plateNumber}</Text>
<Text size="sm" color="secondary">
{visit.vehicle.manufacturer} {visit.vehicle.model} ({visit.vehicle.year})
</Text>
</div>
),
},
{
key: 'customer',
header: 'العميل',
render: (visit: MaintenanceVisitWithRelations) => (
<div>
<Text weight="medium">{visit.customer.name}</Text>
{visit.customer.phone && (
<Text size="sm" color="secondary">{visit.customer.phone}</Text>
)}
</div>
),
},
{
key: 'maintenanceJobs',
header: 'أعمال الصيانة',
render: (visit: MaintenanceVisitWithRelations) => {
try {
const jobs = JSON.parse(visit.maintenanceJobs);
return (
<div>
<Text weight="medium">
{jobs.length > 1 ? `${jobs.length} أعمال صيانة` : jobs[0]?.job || 'غير محدد'}
</Text>
<Text size="sm" color="secondary" className="line-clamp-2">
{visit.description}
</Text>
</div>
);
} catch {
return (
<div>
<Text weight="medium">غير محدد</Text>
<Text size="sm" color="secondary" className="line-clamp-2">
{visit.description}
</Text>
</div>
);
}
},
},
{
key: 'cost',
header: 'التكلفة',
render: (visit: MaintenanceVisitWithRelations) => (
<Text weight="medium" className="font-mono">
{formatCurrency(visit.cost)}
</Text>
),
},
{
key: 'paymentStatus',
header: 'حالة الدفع',
render: (visit: MaintenanceVisitWithRelations) => (
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getPaymentStatusColor(visit.paymentStatus)}`}>
{PAYMENT_STATUS_NAMES[visit.paymentStatus as keyof typeof PAYMENT_STATUS_NAMES]}
</span>
),
},
{
key: 'kilometers',
header: 'الكيلومترات',
render: (visit: MaintenanceVisitWithRelations) => (
<Text className="font-mono">
{formatNumber(visit.kilometers)} كم
</Text>
),
},
{
key: 'actions',
header: 'الإجراءات',
render: (visit: MaintenanceVisitWithRelations) => (
<div className="flex gap-2">
{onView ? (
<Button
size="sm"
variant="outline"
onClick={() => onView(visit)}
>
عرض
</Button>
) : (
<Link to={`/maintenance-visits/${visit.id}`}>
<Button size="sm" variant="outline">
عرض
</Button>
</Link>
)}
{onEdit && (
<Button
size="sm"
variant="outline"
onClick={() => onEdit(visit)}
>
تعديل
</Button>
)}
<Button
size="sm"
variant="outline"
className="text-red-600 border-red-300 hover:bg-red-50"
onClick={() => handleDelete(visit.id)}
>
حذف
</Button>
</div>
),
},
];
return (
<div className="space-y-4">
<div className="bg-white rounded-lg shadow-sm border">
{visits.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 text-lg mb-2">🔧</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
لا توجد زيارات صيانة
</h3>
<p className="text-gray-500">
لم يتم العثور على أي زيارات صيانة. قم بإضافة زيارة جديدة للبدء.
</p>
</div>
) : (
<DataTable
data={visits}
columns={columns}
emptyMessage="لم يتم العثور على أي زيارات صيانة"
/>
)}
</div>
{/* Delete Confirmation Modal */}
{deleteVisitId !== null && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">تأكيد الحذف</h3>
<p className="text-gray-600 mb-6">
هل أنت متأكد من حذف زيارة الصيانة هذه؟ سيتم حذف جميع البيانات المرتبطة بها نهائياً.
</p>
<div className="flex gap-3 justify-end">
<Button
variant="outline"
onClick={() => setDeleteVisitId(null)}
>
إلغاء
</Button>
<Button
variant="outline"
className="text-red-600 border-red-300 hover:bg-red-50"
onClick={confirmDelete}
>
حذف
</Button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,2 @@
export { MaintenanceVisitForm } from './MaintenanceVisitForm';
export { MaintenanceVisitList } from './MaintenanceVisitList';

View File

@@ -0,0 +1,212 @@
import { useState, useMemo } from 'react';
import { DataTable } from '~/components/ui/DataTable';
import { Button } from '~/components/ui/Button';
import { Text } from '~/components/ui/Text';
import { processTableData, type TableState } from '~/lib/table-utils';
import { useSettings } from '~/contexts/SettingsContext';
import type { Customer } from '~/types/database';
interface EnhancedCustomerTableProps {
customers: Customer[];
loading?: boolean;
onEdit?: (customer: Customer) => void;
onDelete?: (customer: Customer) => void;
onView?: (customer: Customer) => void;
}
export function EnhancedCustomerTable({
customers,
loading = false,
onEdit,
onDelete,
onView,
}: EnhancedCustomerTableProps) {
const { formatDate } = useSettings();
const [tableState, setTableState] = useState<TableState>({
search: '',
filters: {},
sort: { key: 'name', direction: 'asc' },
pagination: { page: 1, pageSize: 10 },
});
// Define searchable fields
const searchableFields = ['name', 'phone', 'email', 'address'] as const;
// Process table data
const processedData = useMemo(() => {
return processTableData(customers, tableState, searchableFields);
}, [customers, tableState]);
// Handle sorting
const handleSort = (key: string, direction: 'asc' | 'desc') => {
setTableState(prev => ({
...prev,
sort: { key, direction },
}));
};
// Handle page change
const handlePageChange = (page: number) => {
setTableState(prev => ({
...prev,
pagination: { ...prev.pagination, page },
}));
};
// Define table columns
const columns = [
{
key: 'name' as keyof Customer,
header: 'اسم العميل',
sortable: true,
filterable: true,
render: (customer: Customer) => (
<div>
<Text weight="medium">{customer.name}</Text>
</div>
),
},
{
key: 'phone' as keyof Customer,
header: 'رقم الهاتف',
sortable: true,
filterable: true,
render: (customer: Customer) => (
<Text dir="ltr" className="text-left">
{customer.phone || '-'}
</Text>
),
},
{
key: 'email' as keyof Customer,
header: 'البريد الإلكتروني',
sortable: true,
filterable: true,
render: (customer: Customer) => (
<Text dir="ltr" className="text-left">
{customer.email || '-'}
</Text>
),
},
{
key: 'address' as keyof Customer,
header: 'العنوان',
filterable: true,
render: (customer: Customer) => (
<Text className="max-w-xs truncate" title={customer.address || undefined}>
{customer.address || '-'}
</Text>
),
},
{
key: 'createdDate' as keyof Customer,
header: 'تاريخ الإنشاء',
sortable: true,
render: (customer: Customer) => (
<Text size="sm" color="secondary">
{formatDate(customer.createdDate)}
</Text>
),
},
];
return (
<div className="space-y-4">
{/* Table Header */}
<div className="flex justify-between items-center">
<div>
<Text size="lg" weight="semibold">
قائمة العملاء
</Text>
<Text size="sm" color="secondary">
إدارة بيانات العملاء
</Text>
</div>
<div className="flex items-center space-x-2 space-x-reverse">
<Text size="sm" color="secondary">
المجموع: {processedData.originalCount}
</Text>
{processedData.filteredCount !== processedData.originalCount && (
<Text size="sm" color="secondary">
(مفلتر: {processedData.filteredCount})
</Text>
)}
</div>
</div>
{/* Enhanced Data Table */}
<DataTable
data={processedData.data}
columns={columns}
loading={loading}
emptyMessage="لا يوجد عملاء مسجلين"
searchable
searchPlaceholder="البحث في العملاء..."
filterable
onSort={handleSort}
sortKey={tableState.sort?.key}
sortDirection={tableState.sort?.direction}
pagination={{
enabled: true,
currentPage: tableState.pagination.page,
pageSize: tableState.pagination.pageSize,
totalItems: processedData.filteredCount,
onPageChange: handlePageChange,
}}
actions={{
label: 'الإجراءات',
render: (customer: Customer) => (
<div className="flex items-center space-x-2 space-x-reverse">
{onView && (
<Button
size="sm"
variant="ghost"
onClick={() => onView(customer)}
icon={
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
}
>
عرض
</Button>
)}
{onEdit && (
<Button
size="sm"
variant="ghost"
onClick={() => onEdit(customer)}
icon={
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
}
>
تعديل
</Button>
)}
{onDelete && (
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(customer)}
icon={
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
}
>
حذف
</Button>
)}
</div>
),
}}
/>
</div>
);
}

View File

@@ -0,0 +1,214 @@
import { useState, useRef, useEffect } from "react";
import { Input } from "./Input";
interface AutocompleteOption {
value: string;
label: string;
data?: any;
}
interface AutocompleteInputProps {
name?: string;
label?: string;
placeholder?: string;
value: string;
onChange: (value: string) => void;
onSelect?: (option: AutocompleteOption) => void;
options: AutocompleteOption[];
error?: string;
required?: boolean;
disabled?: boolean;
loading?: boolean;
}
export function AutocompleteInput({
name,
label,
placeholder,
value,
onChange,
onSelect,
options,
error,
required,
disabled,
loading
}: AutocompleteInputProps) {
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(-1);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// Filter options based on input value
const filteredOptions = options.filter(option =>
option.label.toLowerCase().includes(value.toLowerCase()) ||
option.value.toLowerCase().includes(value.toLowerCase())
);
// Handle input change
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
onChange(newValue);
setIsOpen(true);
setHighlightedIndex(-1);
};
// Handle option selection
const handleOptionSelect = (option: AutocompleteOption) => {
onChange(option.value);
onSelect?.(option);
setIsOpen(false);
setHighlightedIndex(-1);
};
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) {
if (e.key === "ArrowDown" || e.key === "Enter") {
setIsOpen(true);
return;
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setHighlightedIndex(prev =>
prev < filteredOptions.length - 1 ? prev + 1 : 0
);
break;
case "ArrowUp":
e.preventDefault();
setHighlightedIndex(prev =>
prev > 0 ? prev - 1 : filteredOptions.length - 1
);
break;
case "Enter":
e.preventDefault();
if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
handleOptionSelect(filteredOptions[highlightedIndex]);
}
break;
case "Escape":
setIsOpen(false);
setHighlightedIndex(-1);
break;
}
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// Scroll highlighted option into view
useEffect(() => {
if (highlightedIndex >= 0 && listRef.current) {
const highlightedElement = listRef.current.children[highlightedIndex] as HTMLElement;
if (highlightedElement) {
highlightedElement.scrollIntoView({
block: "nearest",
behavior: "smooth"
});
}
}
}, [highlightedIndex]);
return (
<div ref={containerRef} className="relative">
<Input
ref={inputRef}
name={name}
label={label}
placeholder={placeholder}
value={value}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => setIsOpen(true)}
error={error}
required={required}
disabled={disabled}
endIcon={
<div className="pointer-events-auto">
{loading ? (
<svg className="animate-spin 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>
) : isOpen ? (
<button
type="button"
onClick={() => setIsOpen(false)}
className="text-gray-400 hover:text-gray-600"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</button>
) : (
<button
type="button"
onClick={() => setIsOpen(true)}
className="text-gray-400 hover:text-gray-600"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
)}
</div>
}
/>
{/* Dropdown */}
{isOpen && filteredOptions.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto">
<ul ref={listRef} className="py-1">
{filteredOptions.map((option, index) => (
<li
key={option.value}
className={`px-3 py-2 cursor-pointer text-sm ${
index === highlightedIndex
? "bg-blue-50 text-blue-900"
: "text-gray-900 hover:bg-gray-50"
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleOptionSelect(option);
}}
onMouseDown={(e) => {
e.preventDefault(); // Prevent input from losing focus
}}
onMouseEnter={() => setHighlightedIndex(index)}
>
<div className="font-medium">{option.value}</div>
{option.label !== option.value && (
<div className="text-xs text-gray-500 mt-1">{option.label}</div>
)}
</li>
))}
</ul>
</div>
)}
{/* No results */}
{isOpen && filteredOptions.length === 0 && value.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg">
<div className="px-3 py-2 text-sm text-gray-500 text-center">
لا توجد نتائج مطابقة
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { ReactNode, ButtonHTMLAttributes } from 'react';
import { getButtonClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className'> {
children: ReactNode;
className?: string;
config?: Partial<LayoutConfig>;
variant?: 'primary' | 'secondary' | 'danger' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
loading?: boolean;
icon?: ReactNode;
iconPosition?: 'start' | 'end';
}
export function Button({
children,
className = '',
config = {},
variant = 'primary',
size = 'md',
fullWidth = false,
loading = false,
icon,
iconPosition = 'start',
disabled,
...props
}: ButtonProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const baseClasses = getButtonClasses(variant as any, size);
const variantClasses = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500 disabled:bg-blue-300',
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-900 focus:ring-gray-500 disabled:bg-gray-100',
danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500 disabled:bg-red-300',
outline: 'border border-gray-300 bg-white hover:bg-gray-50 text-gray-700 focus:ring-blue-500 disabled:bg-gray-50',
ghost: 'bg-transparent hover:bg-gray-100 text-gray-700 focus:ring-gray-500 disabled:text-gray-400',
};
const fullWidthClass = fullWidth ? 'w-full' : '';
const disabledClass = (disabled || loading) ? 'cursor-not-allowed opacity-50' : '';
const iconSpacing = layoutConfig.direction === 'rtl' ? 'space-x-reverse' : '';
const iconOrderClass = iconPosition === 'end' ? 'flex-row-reverse' : '';
return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${fullWidthClass} ${disabledClass} ${iconSpacing} ${iconOrderClass} ${className}`}
disabled={disabled || loading}
dir={layoutConfig.direction}
{...props}
>
{loading && (
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-current"
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"
/>
<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"
/>
</svg>
)}
{icon && iconPosition === 'start' && !loading && (
<span className={layoutConfig.direction === 'rtl' ? 'ml-2' : 'mr-2'}>
{icon}
</span>
)}
<span>{children}</span>
{icon && iconPosition === 'end' && !loading && (
<span className={layoutConfig.direction === 'rtl' ? 'mr-2' : 'ml-2'}>
{icon}
</span>
)}
</button>
);
}

118
app/components/ui/Card.tsx Normal file
View File

@@ -0,0 +1,118 @@
import { ReactNode } from 'react';
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface CardProps {
children: ReactNode;
className?: string;
config?: Partial<LayoutConfig>;
padding?: 'none' | 'sm' | 'md' | 'lg';
shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
border?: boolean;
hover?: boolean;
}
export function Card({
children,
className = '',
config = {},
padding = 'md',
shadow = 'md',
rounded = 'lg',
border = true,
hover = false
}: CardProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const paddingClasses = {
none: '',
sm: 'p-3',
md: 'p-4 sm:p-6',
lg: 'p-6 sm:p-8',
};
const shadowClasses = {
none: '',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
xl: 'shadow-xl',
};
const roundedClasses = {
none: '',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
};
const borderClass = border ? 'border border-gray-200' : '';
const hoverClass = hover ? 'hover:shadow-lg transition-shadow duration-200' : '';
return (
<div
className={`bg-white ${paddingClasses[padding]} ${shadowClasses[shadow]} ${roundedClasses[rounded]} ${borderClass} ${hoverClass} ${className}`}
dir={layoutConfig.direction}
>
{children}
</div>
);
}
interface CardHeaderProps {
children: ReactNode;
className?: string;
config?: Partial<LayoutConfig>;
}
export function CardHeader({ children, className = '', config = {} }: CardHeaderProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
return (
<div
className={`border-b border-gray-200 pb-4 mb-4 ${className}`}
dir={layoutConfig.direction}
>
{children}
</div>
);
}
interface CardBodyProps {
children: ReactNode;
className?: string;
config?: Partial<LayoutConfig>;
}
export function CardBody({ children, className = '', config = {} }: CardBodyProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
return (
<div
className={className}
dir={layoutConfig.direction}
>
{children}
</div>
);
}
interface CardFooterProps {
children: ReactNode;
className?: string;
config?: Partial<LayoutConfig>;
}
export function CardFooter({ children, className = '', config = {} }: CardFooterProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
return (
<div
className={`border-t border-gray-200 pt-4 mt-4 ${className}`}
dir={layoutConfig.direction}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,422 @@
import { ReactNode, memo, useState, useMemo } from 'react';
import { Text } from './Text';
import { Button } from './Button';
import { Input } from './Input';
import { Select } from './Select';
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface Column<T> {
key: keyof T | string;
header: string;
render?: (item: T) => ReactNode;
sortable?: boolean;
filterable?: boolean;
filterType?: 'text' | 'select' | 'date' | 'number';
filterOptions?: { value: string; label: string }[];
className?: string;
width?: string;
}
interface FilterState {
[key: string]: string;
}
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
loading?: boolean;
emptyMessage?: string;
className?: string;
config?: Partial<LayoutConfig>;
onSort?: (key: string, direction: 'asc' | 'desc') => void;
sortKey?: string;
sortDirection?: 'asc' | 'desc';
searchable?: boolean;
searchPlaceholder?: string;
filterable?: boolean;
pagination?: {
enabled: boolean;
pageSize?: number;
currentPage?: number;
totalItems?: number;
onPageChange?: (page: number) => void;
};
actions?: {
label: string;
render: (item: T) => ReactNode;
};
}
export const DataTable = memo(function DataTable<T extends Record<string, any>>({
data,
columns,
loading = false,
emptyMessage = "لا توجد بيانات",
className = '',
config = {},
onSort,
sortKey,
sortDirection,
searchable = false,
searchPlaceholder = "البحث...",
filterable = false,
pagination,
actions,
}: DataTableProps<T>) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const [searchTerm, setSearchTerm] = useState('');
const [filters, setFilters] = useState<FilterState>({});
const handleSort = (key: string) => {
if (!onSort) return;
const newDirection = sortKey === key && sortDirection === 'asc' ? 'desc' : 'asc';
onSort(key, newDirection);
};
const handleFilterChange = (columnKey: string, value: string) => {
setFilters(prev => ({
...prev,
[columnKey]: value,
}));
};
// Filter and search data
const filteredData = useMemo(() => {
let result = [...data];
// Apply search
if (searchable && searchTerm) {
result = result.filter(item => {
return columns.some(column => {
const value = item[column.key];
if (value == null) return false;
return String(value).toLowerCase().includes(searchTerm.toLowerCase());
});
});
}
// Apply column filters
if (filterable) {
Object.entries(filters).forEach(([columnKey, filterValue]) => {
if (filterValue) {
result = result.filter(item => {
const value = item[columnKey];
if (value == null) return false;
return String(value).toLowerCase().includes(filterValue.toLowerCase());
});
}
});
}
return result;
}, [data, searchTerm, filters, columns, searchable, filterable]);
// Paginate data
const paginatedData = useMemo(() => {
if (!pagination?.enabled) return filteredData;
const pageSize = pagination.pageSize || 10;
const currentPage = pagination.currentPage || 1;
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return filteredData.slice(startIndex, endIndex);
}, [filteredData, pagination]);
const totalPages = pagination?.enabled
? Math.ceil(filteredData.length / (pagination.pageSize || 10))
: 1;
if (loading) {
return (
<div className={`bg-white rounded-lg border border-gray-200 ${className}`} dir={layoutConfig.direction}>
<div className="p-8 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<Text color="secondary">جاري التحميل...</Text>
</div>
</div>
);
}
if (data.length === 0) {
return (
<div className={`bg-white rounded-lg border border-gray-200 ${className}`} dir={layoutConfig.direction}>
<div className="p-8 text-center">
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<Text size="lg" weight="medium" className="mb-2">
لا توجد بيانات
</Text>
<Text color="secondary">{emptyMessage}</Text>
</div>
</div>
);
}
return (
<div className={`bg-white rounded-lg border border-gray-200 overflow-hidden ${className}`} dir={layoutConfig.direction}>
{/* Search and Filters */}
{(searchable || filterable) && (
<div className="p-4 border-b border-gray-200 bg-gray-50">
<div className="flex flex-col space-y-4">
{/* Search */}
{searchable && (
<div className="flex-1">
<Input
placeholder={searchPlaceholder}
value={searchTerm}
onChange={(e) => setSearchTerm(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>
}
/>
</div>
)}
{/* Column Filters */}
{filterable && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{columns
.filter(column => column.filterable)
.map(column => (
<div key={`filter-${column.key}`}>
{column.filterType === 'select' && column.filterOptions ? (
<Select
placeholder={`تصفية ${column.header}`}
value={filters[column.key as string] || ''}
onChange={(e) => handleFilterChange(column.key as string, e.target.value)}
options={[
{ value: '', label: `جميع ${column.header}` },
...column.filterOptions
]}
/>
) : (
<Input
placeholder={`تصفية ${column.header}`}
value={filters[column.key as string] || ''}
onChange={(e) => handleFilterChange(column.key as string, e.target.value)}
/>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Table */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{columns.map((column) => (
<th
key={`header-${column.key}`}
className={`px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider ${column.className || ''}`}
style={column.width ? { width: column.width } : undefined}
>
{column.sortable && onSort ? (
<button
onClick={() => handleSort(column.key as string)}
className="group inline-flex items-center space-x-1 space-x-reverse hover:text-gray-700"
>
<span>{column.header}</span>
<span className="ml-2 flex-none rounded text-gray-400 group-hover:text-gray-500">
{sortKey === column.key ? (
sortDirection === 'asc' ? (
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clipRule="evenodd" />
</svg>
) : (
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
)
) : (
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M5 12a1 1 0 102 0V6.414l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L5 6.414V12zM15 8a1 1 0 10-2 0v5.586l-1.293-1.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L15 13.586V8z" />
</svg>
)}
</span>
</button>
) : (
column.header
)}
</th>
))}
{actions && (
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
{actions.label}
</th>
)}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{paginatedData.map((item, rowIndex) => {
// Use item.id if available, otherwise fall back to rowIndex
const rowKey = item.id ? `row-${item.id}` : `row-${rowIndex}`;
return (
<tr key={rowKey} className="hover:bg-gray-50">
{columns.map((column) => (
<td
key={`${rowKey}-${column.key}`}
className={`px-6 py-4 whitespace-nowrap text-sm text-gray-900 ${column.className || ''}`}
>
{column.render
? column.render(item)
: String(item[column.key] || '')
}
</td>
))}
{actions && (
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{actions.render(item)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
{/* Pagination */}
{pagination?.enabled && totalPages > 1 && (
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
<Pagination
currentPage={pagination.currentPage || 1}
totalPages={totalPages}
onPageChange={pagination.onPageChange || (() => {})}
config={config}
/>
</div>
)}
{/* Results Summary */}
{(searchable || filterable || pagination?.enabled) && (
<div className="px-4 py-2 border-t border-gray-200 bg-gray-50">
<Text size="sm" color="secondary">
عرض {paginatedData.length} من {filteredData.length}
{filteredData.length !== data.length && ` (مفلتر من ${data.length})`}
</Text>
</div>
)}
</div>
);
}) as <T extends Record<string, any>>(props: DataTableProps<T>) => JSX.Element;
interface PaginationProps {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
className?: string;
config?: Partial<LayoutConfig>;
}
export const Pagination = memo(function Pagination({
currentPage,
totalPages,
onPageChange,
className = '',
config = {},
}: PaginationProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
if (totalPages <= 1) return null;
const getPageNumbers = () => {
const pages = [];
const maxVisible = 5;
if (totalPages <= maxVisible) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 4; i++) {
pages.push(i);
}
pages.push('...');
pages.push(totalPages);
} else if (currentPage >= totalPages - 2) {
pages.push(1);
pages.push('...');
for (let i = totalPages - 3; i <= totalPages; i++) {
pages.push(i);
}
} else {
pages.push(1);
pages.push('...');
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pages.push(i);
}
pages.push('...');
pages.push(totalPages);
}
}
return pages;
};
return (
<div className={`flex items-center justify-between ${className}`} dir={layoutConfig.direction}>
<div className="flex items-center space-x-2 space-x-reverse">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
>
السابق
</Button>
<div className="flex items-center space-x-1 space-x-reverse">
{getPageNumbers().map((page, index) => (
<div key={index}>
{page === '...' ? (
<span className="px-3 py-2 text-gray-500">...</span>
) : (
<Button
variant={currentPage === page ? "primary" : "outline"}
size="sm"
onClick={() => onPageChange(page as number)}
>
{page}
</Button>
)}
</div>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
التالي
</Button>
</div>
<Text color="secondary" size="sm">
صفحة {currentPage} من {totalPages}
</Text>
</div>
);
});

233
app/components/ui/Form.tsx Normal file
View File

@@ -0,0 +1,233 @@
import { ReactNode, FormHTMLAttributes } from 'react';
import { Form as RemixForm } from '@remix-run/react';
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
import { Button } from './Button';
import { Text } from './Text';
interface FormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, 'className'> {
children: ReactNode;
className?: string;
config?: Partial<LayoutConfig>;
title?: string;
description?: string;
loading?: boolean;
error?: string;
success?: string;
actions?: ReactNode;
spacing?: 'sm' | 'md' | 'lg';
}
export function Form({
children,
className = '',
config = {},
title,
description,
loading = false,
error,
success,
actions,
spacing = 'md',
...props
}: FormProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const spacingClasses = {
sm: 'space-y-4',
md: 'space-y-6',
lg: 'space-y-8',
};
return (
<div className={`${className}`} dir={layoutConfig.direction}>
{/* Form Header */}
{(title || description) && (
<div className="mb-6">
{title && (
<Text as="h2" size="xl" weight="semibold" className="mb-2">
{title}
</Text>
)}
{description && (
<Text color="secondary">
{description}
</Text>
)}
</div>
)}
{/* Success Message */}
{success && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center">
<svg className="h-5 w-5 text-green-400 ml-2" 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>
<Text color="success" size="sm">
{success}
</Text>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center">
<svg className="h-5 w-5 text-red-400 ml-2" fill="currentColor" viewBox="0 0 20 20">
<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>
<Text color="error" size="sm">
{error}
</Text>
</div>
</div>
)}
{/* Form Content */}
<RemixForm className={spacingClasses[spacing]} {...props}>
{children}
{/* Form Actions */}
{actions && (
<div className="pt-4 border-t border-gray-200">
{actions}
</div>
)}
</RemixForm>
{/* Loading Overlay */}
{loading && (
<div className="absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center rounded-lg">
<div className="flex items-center space-x-2 space-x-reverse">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<Text color="secondary">جاري المعالجة...</Text>
</div>
</div>
)}
</div>
);
}
// Form Actions Component
interface FormActionsProps {
children: ReactNode;
className?: string;
config?: Partial<LayoutConfig>;
justify?: 'start' | 'center' | 'end' | 'between';
spacing?: 'sm' | 'md' | 'lg';
}
export function FormActions({
children,
className = '',
config = {},
justify = 'end',
spacing = 'md',
}: FormActionsProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const justifyClasses = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between',
};
const spacingClasses = {
sm: 'space-x-2 space-x-reverse',
md: 'space-x-4 space-x-reverse',
lg: 'space-x-6 space-x-reverse',
};
return (
<div
className={`flex ${justifyClasses[justify]} ${spacingClasses[spacing]} ${className}`}
dir={layoutConfig.direction}
>
{children}
</div>
);
}
// Form Section Component
interface FormSectionProps {
children: ReactNode;
title?: string;
description?: string;
className?: string;
config?: Partial<LayoutConfig>;
}
export function FormSection({
children,
title,
description,
className = '',
config = {},
}: FormSectionProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
return (
<div className={`${className}`} dir={layoutConfig.direction}>
{(title || description) && (
<div className="mb-4">
{title && (
<Text as="h3" size="lg" weight="medium" className="mb-1">
{title}
</Text>
)}
{description && (
<Text color="secondary" size="sm">
{description}
</Text>
)}
</div>
)}
<div className="space-y-4">
{children}
</div>
</div>
);
}
// Form Grid Component
interface FormGridProps {
children: ReactNode;
columns?: 1 | 2 | 3 | 4;
className?: string;
config?: Partial<LayoutConfig>;
gap?: 'sm' | 'md' | 'lg';
}
export function FormGrid({
children,
columns = 2,
className = '',
config = {},
gap = 'md',
}: FormGridProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const columnClasses = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
};
const gapClasses = {
sm: 'gap-4',
md: 'gap-6',
lg: 'gap-8',
};
return (
<div
className={`grid ${columnClasses[columns]} ${gapClasses[gap]} ${className}`}
dir={layoutConfig.direction}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { ReactNode } from 'react';
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface FormFieldProps {
children: ReactNode;
label?: string;
error?: string;
helperText?: string;
required?: boolean;
className?: string;
config?: Partial<LayoutConfig>;
htmlFor?: string;
}
export function FormField({
children,
label,
error,
helperText,
required = false,
className = '',
config = {},
htmlFor,
}: FormFieldProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
return (
<div className={`${className}`} dir={layoutConfig.direction}>
{label && (
<label
htmlFor={htmlFor}
className={`block text-sm font-medium mb-2 ${error ? 'text-red-700' : 'text-gray-700'} ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}
>
{label}
{required && <span className="text-red-500 mr-1">*</span>}
</label>
)}
{children}
{error && (
<p className={`mt-2 text-sm text-red-600 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
{error}
</p>
)}
{helperText && !error && (
<p className={`mt-2 text-sm text-gray-500 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
{helperText}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { InputHTMLAttributes, forwardRef, useId } from 'react';
import { getFormInputClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> {
className?: string;
config?: Partial<LayoutConfig>;
label?: string;
error?: string;
helperText?: string;
fullWidth?: boolean;
startIcon?: React.ReactNode;
endIcon?: React.ReactNode;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(({
className = '',
config = {},
label,
error,
helperText,
fullWidth = true,
startIcon,
endIcon,
id,
...props
}, ref) => {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const inputClasses = getFormInputClasses(!!error);
const fullWidthClass = fullWidth ? 'w-full' : '';
const hasIconsClass = (startIcon || endIcon) ? 'relative' : '';
const generatedId = useId();
const inputId = id || generatedId;
return (
<div className={`${fullWidthClass} ${className}`} dir={layoutConfig.direction}>
{label && (
<label
htmlFor={inputId}
className={`block text-sm font-medium mb-2 ${error ? 'text-red-700' : 'text-gray-700'} ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}
>
{label}
</label>
)}
<div className={`relative ${hasIconsClass}`}>
{startIcon && (
<div className={`absolute inset-y-0 ${layoutConfig.direction === 'rtl' ? 'right-0 pr-3' : 'left-0 pl-3'} flex items-center pointer-events-none`}>
<span className="text-gray-400 sm:text-sm">
{startIcon}
</span>
</div>
)}
<input
ref={ref}
id={inputId}
className={`${inputClasses} ${startIcon ? (layoutConfig.direction === 'rtl' ? 'pr-10' : 'pl-10') : ''} ${endIcon ? (layoutConfig.direction === 'rtl' ? 'pl-10' : 'pr-10') : ''}`}
dir={layoutConfig.direction}
{...props}
/>
{endIcon && (
<div className={`absolute inset-y-0 ${layoutConfig.direction === 'rtl' ? 'left-0 pl-3' : 'right-0 pr-3'} flex items-center pointer-events-none`}>
<span className="text-gray-400 sm:text-sm">
{endIcon}
</span>
</div>
)}
</div>
{error && (
<p className={`mt-2 text-sm text-red-600 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
{error}
</p>
)}
{helperText && !error && (
<p className={`mt-2 text-sm text-gray-500 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
{helperText}
</p>
)}
</div>
);
});

220
app/components/ui/Modal.tsx Normal file
View File

@@ -0,0 +1,220 @@
import { ReactNode, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button } from './Button';
import { Text } from './Text';
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
className?: string;
config?: Partial<LayoutConfig>;
showCloseButton?: boolean;
}
export function Modal({
isOpen,
onClose,
title,
children,
size = 'md',
className = '',
config = {},
showCloseButton = true,
}: ModalProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('keydown', handleEscape);
};
}, [isOpen, onClose]);
if (!isOpen || !isMounted) return null;
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-2xl',
xl: 'max-w-4xl',
};
const modalContent = (
<div className="fixed inset-0 z-50 overflow-y-auto" dir={layoutConfig.direction}>
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
{/* Backdrop */}
<div
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
onClick={onClose}
/>
{/* Modal panel */}
<div
className={`relative transform overflow-hidden rounded-lg bg-white text-right shadow-xl transition-all sm:my-8 sm:w-full ${sizeClasses[size]} ${className}`}
>
{/* Header */}
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-4">
<Text as="h3" size="lg" weight="medium">
{title}
</Text>
{showCloseButton && (
<button
onClick={onClose}
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<span className="sr-only">إغلاق</span>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Content */}
<div>
{children}
</div>
</div>
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
}
interface ConfirmModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'warning' | 'info';
config?: Partial<LayoutConfig>;
}
export function ConfirmModal({
isOpen,
onClose,
onConfirm,
title,
message,
confirmText = "تأكيد",
cancelText = "إلغاء",
variant = 'info',
config = {},
}: ConfirmModalProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
const handleConfirm = () => {
onConfirm();
onClose();
};
const getIcon = () => {
switch (variant) {
case 'danger':
return (
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
);
case 'warning':
return (
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-yellow-100 sm:mx-0 sm:h-10 sm:w-10">
<svg className="h-6 w-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
);
default:
return (
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title=""
size="sm"
showCloseButton={false}
config={config}
>
<div className="sm:flex sm:items-start" dir={layoutConfig.direction}>
{getIcon()}
<div className="mt-3 text-center sm:mt-0 sm:mr-4 sm:text-right">
<Text as="h3" size="lg" weight="medium" className="mb-2">
{title}
</Text>
<Text color="secondary" className="mb-4">
{message}
</Text>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex w-full sm:flex-row space-x-2">
<Button
onClick={handleConfirm}
variant={variant === 'danger' ? 'danger' : 'primary'}
className="w-full sm:w-auto sm:ml-3"
>
{confirmText}
</Button>
<Button
onClick={onClose}
variant="outline"
className="mt-3 w-full sm:mt-0 sm:w-auto"
>
{cancelText}
</Button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,171 @@
import { useState, useRef, useEffect } from "react";
import { Text } from "./Text";
interface Option {
value: string | number;
label: string;
}
interface MultiSelectProps {
name?: string;
label?: string;
options: Option[];
value: (string | number)[];
onChange: (values: (string | number)[]) => void;
placeholder?: string;
error?: string;
required?: boolean;
disabled?: boolean;
className?: string;
}
export function MultiSelect({
name,
label,
options,
value,
onChange,
placeholder = "اختر العناصر...",
error,
required,
disabled,
className = ""
}: MultiSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleToggleOption = (optionValue: string | number) => {
if (disabled) return;
const newValue = value.includes(optionValue)
? value.filter(v => v !== optionValue)
: [...value, optionValue];
onChange(newValue);
};
const handleRemoveItem = (optionValue: string | number) => {
if (disabled) return;
onChange(value.filter(v => v !== optionValue));
};
const selectedOptions = options.filter(option => value.includes(option.value));
const displayText = selectedOptions.length > 0
? `تم اختيار ${selectedOptions.length} عنصر`
: placeholder;
return (
<div className={`relative ${className}`} ref={containerRef}>
{label && (
<label className="block text-sm font-medium text-gray-700 mb-2">
{label}
{required && <span className="text-red-500 mr-1">*</span>}
</label>
)}
{/* Hidden input for form submission */}
{name && (
<input
type="hidden"
name={name}
value={JSON.stringify(value)}
/>
)}
{/* Selected items display */}
{selectedOptions.length > 0 && (
<div className="mb-2 flex flex-wrap gap-2">
{selectedOptions.map((option) => (
<span
key={option.value}
className="inline-flex items-center px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded-full"
>
{option.label}
{!disabled && (
<button
type="button"
onClick={() => handleRemoveItem(option.value)}
className="mr-1 text-blue-600 hover:text-blue-800"
>
×
</button>
)}
</span>
))}
</div>
)}
{/* Dropdown trigger */}
<div
className={`w-full px-3 py-2 border rounded-lg shadow-sm cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white ${
error
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300'
} ${disabled ? 'bg-gray-50 cursor-not-allowed' : ''}`}
onClick={() => !disabled && setIsOpen(!isOpen)}
>
<div className="flex items-center justify-between">
<span className={selectedOptions.length > 0 ? 'text-gray-900' : 'text-gray-500'}>
{displayText}
</span>
<svg
className={`h-5 w-5 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
viewBox="0 0 20 20"
fill="currentColor"
>
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</div>
</div>
{/* Dropdown menu */}
{isOpen && (
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-auto">
{options.length === 0 ? (
<div className="px-3 py-2 text-gray-500 text-sm">
لا توجد خيارات متاحة
</div>
) : (
options.map((option) => {
const isSelected = value.includes(option.value);
return (
<div
key={option.value}
className={`px-3 py-2 cursor-pointer hover:bg-gray-50 flex items-center justify-between ${
isSelected ? 'bg-blue-50 text-blue-700' : 'text-gray-900'
}`}
onClick={() => handleToggleOption(option.value)}
>
<span>{option.label}</span>
{isSelected && (
<svg className="h-4 w-4 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
</div>
);
})
)}
</div>
)}
{error && (
<Text size="sm" color="error" className="mt-1">
{error}
</Text>
)}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useState, useEffect } from 'react';
import { Input } from './Input';
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface SearchInputProps {
placeholder?: string;
onSearch: (query: string) => void;
debounceMs?: number;
className?: string;
config?: Partial<LayoutConfig>;
initialValue?: string;
}
export function SearchInput({
placeholder = "البحث...",
onSearch,
debounceMs = 300,
className = '',
config = {},
initialValue = '',
}: SearchInputProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const [query, setQuery] = useState(initialValue);
useEffect(() => {
const timer = setTimeout(() => {
onSearch(query);
}, debounceMs);
return () => clearTimeout(timer);
}, [query, onSearch, debounceMs]);
return (
<div className={`relative ${className}`} dir={layoutConfig.direction}>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<svg
className="h-5 w-5 text-gray-400"
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>
</div>
<Input
type="text"
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pr-10"
/>
{query && (
<button
onClick={() => setQuery('')}
className="absolute inset-y-0 left-0 pl-3 flex items-center"
>
<svg
className="h-5 w-5 text-gray-400 hover:text-gray-600"
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>
);
}

View File

@@ -0,0 +1,97 @@
import { SelectHTMLAttributes, forwardRef } from 'react';
import { getFormInputClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'className'> {
className?: string;
config?: Partial<LayoutConfig>;
label?: string;
error?: string;
helperText?: string;
fullWidth?: boolean;
options: SelectOption[];
placeholder?: string;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(({
className = '',
config = {},
label,
error,
helperText,
fullWidth = true,
options,
placeholder,
id,
...props
}, ref) => {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const inputClasses = getFormInputClasses(!!error);
const fullWidthClass = fullWidth ? 'w-full' : '';
const inputId = id || `select-${Math.random().toString(36).substr(2, 9)}`;
return (
<div className={`${fullWidthClass} ${className}`} dir={layoutConfig.direction}>
{label && (
<label
htmlFor={inputId}
className={`block text-sm font-medium mb-2 ${error ? 'text-red-700' : 'text-gray-700'} ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}
>
{label}
</label>
)}
<div className="relative">
<select
ref={ref}
id={inputId}
className={`${inputClasses} appearance-none bg-white`}
dir={layoutConfig.direction}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</option>
))}
</select>
{/* Custom dropdown arrow */}
<div className={`absolute inset-y-0 ${layoutConfig.direction === 'rtl' ? 'left-0 pl-3' : 'right-0 pr-3'} flex items-center pointer-events-none`}>
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</div>
</div>
{error && (
<p className={`mt-2 text-sm text-red-600 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
{error}
</p>
)}
{helperText && !error && (
<p className={`mt-2 text-sm text-gray-500 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
{helperText}
</p>
)}
</div>
);
});
Select.displayName = 'Select';

View File

@@ -0,0 +1,73 @@
import { ReactNode } from 'react';
import { getArabicTextClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface TextProps {
children: ReactNode;
className?: string;
config?: Partial<LayoutConfig>;
size?: 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold';
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'muted';
align?: 'left' | 'center' | 'right' | 'justify';
as?: 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
}
export function Text({
children,
className = '',
config = {},
size = 'base',
weight = 'normal',
color = 'primary',
align,
as: Component = 'p'
}: TextProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const sizeClasses = {
xs: 'text-xs',
sm: 'text-sm',
base: 'text-base',
lg: 'text-lg',
xl: 'text-xl',
'2xl': 'text-2xl',
'3xl': 'text-3xl',
'4xl': 'text-4xl',
};
const weightClasses = {
light: 'font-light',
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold',
};
const colorClasses = {
primary: 'text-gray-900 dark:text-gray-100',
secondary: 'text-gray-600 dark:text-gray-400',
success: 'text-green-600 dark:text-green-400',
warning: 'text-amber-600 dark:text-amber-400',
error: 'text-red-600 dark:text-red-400',
muted: 'text-gray-500 dark:text-gray-500',
};
const alignClasses = {
left: layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left',
center: 'text-center',
right: layoutConfig.direction === 'rtl' ? 'text-left' : 'text-right',
justify: 'text-justify',
};
const alignClass = align ? alignClasses[align] : (layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left');
const arabicClasses = getArabicTextClasses(size as any);
return (
<Component
className={`${arabicClasses} ${sizeClasses[size]} ${weightClasses[weight]} ${colorClasses[color]} ${alignClass} ${className}`}
dir={layoutConfig.direction}
>
{children}
</Component>
);
}

View File

@@ -0,0 +1,72 @@
import { TextareaHTMLAttributes, forwardRef } from 'react';
import { getFormInputClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'className'> {
className?: string;
config?: Partial<LayoutConfig>;
label?: string;
error?: string;
helperText?: string;
fullWidth?: boolean;
resize?: 'none' | 'vertical' | 'horizontal' | 'both';
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(({
className = '',
config = {},
label,
error,
helperText,
fullWidth = true,
resize = 'vertical',
id,
...props
}, ref) => {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const inputClasses = getFormInputClasses(!!error);
const fullWidthClass = fullWidth ? 'w-full' : '';
const resizeClass = {
none: 'resize-none',
vertical: 'resize-y',
horizontal: 'resize-x',
both: 'resize',
}[resize];
const inputId = id || `textarea-${Math.random().toString(36).substr(2, 9)}`;
return (
<div className={`${fullWidthClass} ${className}`} dir={layoutConfig.direction}>
{label && (
<label
htmlFor={inputId}
className={`block text-sm font-medium mb-2 ${error ? 'text-red-700' : 'text-gray-700'} ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}
>
{label}
</label>
)}
<textarea
ref={ref}
id={inputId}
className={`${inputClasses} ${resizeClass} min-h-[80px]`}
dir={layoutConfig.direction}
{...props}
/>
{error && (
<p className={`mt-2 text-sm text-red-600 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
{error}
</p>
)}
{helperText && !error && (
<p className={`mt-2 text-sm text-gray-500 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
{helperText}
</p>
)}
</div>
);
});
Textarea.displayName = 'Textarea';

View File

@@ -0,0 +1,10 @@
export { Text } from './Text';
export { Button } from './Button';
export { Input } from './Input';
export { AutocompleteInput } from './AutocompleteInput';
export { Card, CardHeader, CardBody, CardFooter } from './Card';
export { DataTable, Pagination } from './DataTable';
export { SearchInput } from './SearchInput';
export { Modal, ConfirmModal } from './Modal';
export { Select } from './Select';
export { MultiSelect } from './MultiSelect';

View File

@@ -0,0 +1,217 @@
import { useState, useEffect } from 'react';
import { Form } from '@remix-run/react';
import { Input, Button, Select, Text } from '~/components/ui';
import { validateUser } from '~/lib/validation';
import { AUTH_LEVELS, USER_STATUS } from '~/types/auth';
import type { UserWithoutPassword } from '~/types/database';
interface UserFormProps {
user?: UserWithoutPassword;
onSubmit: (data: FormData) => void;
onCancel: () => void;
loading?: boolean;
currentUserAuthLevel: number;
}
export function UserForm({
user,
onSubmit,
onCancel,
loading = false,
currentUserAuthLevel,
}: UserFormProps) {
const [formData, setFormData] = useState({
name: user?.name || '',
username: user?.username || '',
email: user?.email || '',
password: '',
confirmPassword: '',
authLevel: user?.authLevel || AUTH_LEVELS.USER,
status: user?.status || USER_STATUS.ACTIVE,
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const isEditing = !!user;
// Validate form data
useEffect(() => {
const validationData = {
name: formData.name,
username: formData.username,
email: formData.email,
authLevel: formData.authLevel,
status: formData.status,
};
// Only validate password for new users or when password is provided
if (!isEditing || formData.password) {
validationData.password = formData.password;
}
const validation = validateUser(validationData);
// Add confirm password validation
const newErrors = { ...validation.errors };
if (!isEditing || formData.password) {
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'كلمات المرور غير متطابقة';
}
}
setErrors(newErrors);
}, [formData, isEditing]);
const handleInputChange = (field: string, value: string | number) => {
setFormData(prev => ({ ...prev, [field]: value }));
setTouched(prev => ({ ...prev, [field]: true }));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Mark all fields as touched
const allFields = Object.keys(formData);
setTouched(allFields.reduce((acc, field) => ({ ...acc, [field]: true }), {}));
// Check if form is valid
if (Object.keys(errors).length > 0) {
return;
}
// Create FormData
const submitData = new FormData();
submitData.append('name', formData.name);
submitData.append('username', formData.username);
submitData.append('email', formData.email);
submitData.append('authLevel', formData.authLevel.toString());
submitData.append('status', formData.status);
if (!isEditing || formData.password) {
submitData.append('password', formData.password);
}
if (isEditing) {
submitData.append('userId', user.id.toString());
submitData.append('_action', 'update');
} else {
submitData.append('_action', 'create');
}
onSubmit(submitData);
};
// Get available auth levels based on current user's level
const getAuthLevelOptions = () => {
const options = [];
if (currentUserAuthLevel === AUTH_LEVELS.SUPERADMIN) {
options.push({ value: AUTH_LEVELS.SUPERADMIN, label: 'مدير عام' });
}
if (currentUserAuthLevel <= AUTH_LEVELS.ADMIN) {
options.push({ value: AUTH_LEVELS.ADMIN, label: 'مدير' });
}
options.push({ value: AUTH_LEVELS.USER, label: 'مستخدم' });
return options;
};
const statusOptions = [
{ value: USER_STATUS.ACTIVE, label: 'نشط' },
{ value: USER_STATUS.INACTIVE, label: 'غير نشط' },
];
return (
<Form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="الاسم"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
error={touched.name ? errors.name : undefined}
required
/>
<Input
label="اسم المستخدم"
value={formData.username}
onChange={(e) => handleInputChange('username', e.target.value)}
error={touched.username ? errors.username : undefined}
required
/>
</div>
<Input
label="البريد الإلكتروني"
type="email"
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
error={touched.email ? errors.email : undefined}
required
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label={isEditing ? "كلمة المرور الجديدة (اختياري)" : "كلمة المرور"}
type="password"
value={formData.password}
onChange={(e) => handleInputChange('password', e.target.value)}
error={touched.password ? errors.password : undefined}
required={!isEditing}
/>
<Input
label="تأكيد كلمة المرور"
type="password"
value={formData.confirmPassword}
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
error={touched.confirmPassword ? errors.confirmPassword : undefined}
required={!isEditing || !!formData.password}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Select
label="مستوى الصلاحية"
value={formData.authLevel}
onChange={(e) => handleInputChange('authLevel', parseInt(e.target.value))}
options={getAuthLevelOptions()}
error={touched.authLevel ? errors.authLevel : undefined}
required
/>
<Select
label="الحالة"
value={formData.status}
onChange={(e) => handleInputChange('status', e.target.value)}
options={statusOptions}
error={touched.status ? errors.status : undefined}
required
/>
</div>
<div className="flex justify-start space-x-3 space-x-reverse pt-4">
<Button
type="submit"
loading={loading}
disabled={Object.keys(errors).length > 0}
>
{isEditing ? 'تحديث المستخدم' : 'إنشاء المستخدم'}
</Button>
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={loading}
>
إلغاء
</Button>
</div>
</Form>
);
}

View File

@@ -0,0 +1,233 @@
import { useState, memo } from 'react';
import { Form } from '@remix-run/react';
import { DataTable, Pagination, Button, Text, ConfirmModal } from '~/components/ui';
import { getAuthLevelName, getStatusName } from '~/lib/user-utils';
import { useSettings } from '~/contexts/SettingsContext';
import { AUTH_LEVELS } from '~/types/auth';
import type { UserWithoutPassword } from '~/types/database';
interface UserListProps {
users: UserWithoutPassword[];
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
onEdit: (user: UserWithoutPassword) => void;
onDelete: (userId: number) => void;
onToggleStatus: (userId: number) => void;
currentUserAuthLevel: number;
loading?: boolean;
}
export const UserList = memo(function UserList({
users,
currentPage,
totalPages,
onPageChange,
onEdit,
onDelete,
onToggleStatus,
currentUserAuthLevel,
loading = false,
}: UserListProps) {
const { formatDate } = useSettings();
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean;
user: UserWithoutPassword | null;
}>({ isOpen: false, user: null });
const [statusModal, setStatusModal] = useState<{
isOpen: boolean;
user: UserWithoutPassword | null;
}>({ isOpen: false, user: null });
const handleDeleteClick = (user: UserWithoutPassword) => {
setDeleteModal({ isOpen: true, user });
};
const handleStatusClick = (user: UserWithoutPassword) => {
setStatusModal({ isOpen: true, user });
};
const handleDeleteConfirm = () => {
if (deleteModal.user) {
onDelete(deleteModal.user.id);
}
setDeleteModal({ isOpen: false, user: null });
};
const handleStatusConfirm = () => {
if (statusModal.user) {
onToggleStatus(statusModal.user.id);
}
setStatusModal({ isOpen: false, user: null });
};
const canEditUser = (user: UserWithoutPassword) => {
// Superadmin can edit anyone
if (currentUserAuthLevel === AUTH_LEVELS.SUPERADMIN) return true;
// Admin cannot edit superadmin
if (currentUserAuthLevel === AUTH_LEVELS.ADMIN && user.authLevel === AUTH_LEVELS.SUPERADMIN) {
return false;
}
return true;
};
const canDeleteUser = (user: UserWithoutPassword) => {
// Same rules as edit
return canEditUser(user);
};
const columns = [
{
key: 'name',
header: 'الاسم',
sortable: true,
render: (user: UserWithoutPassword) => (
<div>
<Text weight="medium">{user.name}</Text>
<Text size="sm" color="secondary">@{user.username}</Text>
</div>
),
},
{
key: 'email',
header: 'البريد الإلكتروني',
sortable: true,
},
{
key: 'authLevel',
header: 'مستوى الصلاحية',
render: (user: UserWithoutPassword) => {
const levelName = getAuthLevelName(user.authLevel);
const colorClass = user.authLevel === AUTH_LEVELS.SUPERADMIN
? 'text-purple-600 bg-purple-100'
: user.authLevel === AUTH_LEVELS.ADMIN
? 'text-blue-600 bg-blue-100'
: 'text-gray-600 bg-gray-100';
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
{levelName}
</span>
);
},
},
{
key: 'status',
header: 'الحالة',
render: (user: UserWithoutPassword) => {
const statusName = getStatusName(user.status);
const colorClass = user.status === 'active'
? 'text-green-600 bg-green-100'
: 'text-red-600 bg-red-100';
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
{statusName}
</span>
);
},
},
{
key: 'createdDate',
header: 'تاريخ الإنشاء',
sortable: true,
render: (user: UserWithoutPassword) => (
<Text size="sm" color="secondary">
{formatDate(user.createdDate)}
</Text>
),
},
{
key: 'actions',
header: 'الإجراءات',
render: (user: UserWithoutPassword) => (
<div className="flex items-center space-x-2 space-x-reverse">
{canEditUser(user) && (
<Button
size="sm"
variant="outline"
onClick={() => onEdit(user)}
className='w-24'
>
تعديل
</Button>
)}
{canEditUser(user) && (
<Button
size="sm"
variant="outline"
onClick={() => handleStatusClick(user)}
className='w-24'
>
{user.status === 'active' ? 'إلغاء تفعيل' : 'تفعيل'}
</Button>
)}
{canDeleteUser(user) && (
<Button
size="sm"
variant="danger"
onClick={() => handleDeleteClick(user)}
className='w-24'
>
حذف
</Button>
)}
</div>
),
},
];
return (
<>
<DataTable
data={users}
columns={columns}
loading={loading}
emptyMessage="لا توجد مستخدمين"
/>
{totalPages > 1 && (
<div className="mt-6">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
</div>
)}
{/* Delete Confirmation Modal */}
<ConfirmModal
isOpen={deleteModal.isOpen}
onClose={() => setDeleteModal({ isOpen: false, user: null })}
onConfirm={handleDeleteConfirm}
title="تأكيد الحذف"
message={`هل أنت متأكد من حذف المستخدم "${deleteModal.user?.name}"؟ هذا الإجراء لا يمكن التراجع عنه.`}
confirmText="حذف"
cancelText="إلغاء"
variant="danger"
/>
{/* Status Toggle Confirmation Modal */}
<ConfirmModal
isOpen={statusModal.isOpen}
onClose={() => setStatusModal({ isOpen: false, user: null })}
onConfirm={handleStatusConfirm}
title={statusModal.user?.status === 'active' ? 'إلغاء تفعيل المستخدم' : 'تفعيل المستخدم'}
message={
statusModal.user?.status === 'active'
? `هل أنت متأكد من إلغاء تفعيل المستخدم "${statusModal.user?.name}`
: `هل أنت متأكد من تفعيل المستخدم "${statusModal.user?.name}`
}
confirmText={statusModal.user?.status === 'active' ? 'إلغاء تفعيل' : 'تفعيل'}
cancelText="إلغاء"
variant={statusModal.user?.status === 'active' ? 'warning' : 'info'}
/>
</>
);
});

View File

@@ -0,0 +1,388 @@
import { Link } from "@remix-run/react";
import { Button } from "~/components/ui/Button";
import { Flex } from "~/components/layout/Flex";
import { useSettings } from "~/contexts/SettingsContext";
import { TRANSMISSION_TYPES, FUEL_TYPES, USE_TYPES, BODY_TYPES } from "~/lib/constants";
import type { VehicleWithOwner, VehicleWithRelations } from "~/types/database";
interface VehicleDetailsViewProps {
vehicle: VehicleWithOwner | VehicleWithRelations;
onEdit?: () => void;
onClose?: () => void;
isLoadingVisits?: boolean;
}
export function VehicleDetailsView({ vehicle, onEdit, onClose, isLoadingVisits }: VehicleDetailsViewProps) {
const { formatDate, formatCurrency, formatNumber } = useSettings();
// Helper functions to get display labels
const getTransmissionLabel = (value: string) => {
return TRANSMISSION_TYPES.find(t => t.value === value)?.label || value;
};
const getFuelLabel = (value: string) => {
return FUEL_TYPES.find(f => f.value === value)?.label || value;
};
const getUseTypeLabel = (value: string) => {
return USE_TYPES.find(u => u.value === value)?.label || value;
};
const getBodyTypeLabel = (value: string) => {
return BODY_TYPES.find(b => b.value === value)?.label || value;
};
return (
<div className="space-y-6">
{/* Enhanced Vehicle Information Section */}
<div className="bg-gradient-to-r from-green-50 to-emerald-50 p-6 rounded-xl border border-green-100">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-gray-900 flex items-center">
<span className="text-green-600 ml-2">🚗</span>
معلومات المركبة
</h3>
<span className="text-sm text-gray-500 bg-white px-3 py-1 rounded-full">
المركبة #{vehicle.id}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="bg-white p-4 rounded-lg shadow-sm">
<label className="block text-sm font-medium text-gray-600 mb-1">رقم اللوحة</label>
<p className="text-xl font-bold text-gray-900 font-mono tracking-wider">
{vehicle.plateNumber}
</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-sm">
<label className="block text-sm font-medium text-gray-600 mb-1">الشركة المصنعة</label>
<p className="text-lg font-semibold text-gray-900">{vehicle.manufacturer}</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-sm">
<label className="block text-sm font-medium text-gray-600 mb-1">الموديل</label>
<p className="text-lg font-semibold text-gray-900">{vehicle.model}</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-sm">
<label className="block text-sm font-medium text-gray-600 mb-1">سنة الصنع</label>
<p className="text-lg font-semibold text-gray-900">{vehicle.year}</p>
</div>
<div className="bg-white p-4 rounded-lg shadow-sm">
<label className="block text-sm font-medium text-gray-600 mb-1">نوع الهيكل</label>
<p className="text-gray-900">{getBodyTypeLabel(vehicle.bodyType)}</p>
</div>
{vehicle.trim && (
<div className="bg-white p-4 rounded-lg shadow-sm">
<label className="block text-sm font-medium text-gray-600 mb-1">الفئة</label>
<p className="text-gray-900">{vehicle.trim}</p>
</div>
)}
</div>
</div>
{/* Technical Specifications */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<span className="text-gray-600 text-xl ml-2"></span>
المواصفات التقنية
</h3>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="bg-gray-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">ناقل الحركة</label>
<p className="text-gray-900 font-medium">{getTransmissionLabel(vehicle.transmission)}</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">نوع الوقود</label>
<p className="text-gray-900 font-medium">{getFuelLabel(vehicle.fuel)}</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">نوع الاستخدام</label>
<p className="text-gray-900 font-medium">{getUseTypeLabel(vehicle.useType)}</p>
</div>
{vehicle.cylinders && (
<div className="bg-gray-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">عدد الأسطوانات</label>
<p className="text-gray-900 font-medium">{vehicle.cylinders}</p>
</div>
)}
{vehicle.engineDisplacement && (
<div className="bg-gray-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">سعة المحرك</label>
<p className="text-gray-900 font-medium">{vehicle.engineDisplacement} لتر</p>
</div>
)}
</div>
</div>
</div>
{/* Owner Information */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<Flex justify="between" align="center">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<span className="text-gray-600 text-xl ml-2">👤</span>
معلومات المالك
</h3>
<Link
to={`/customers?search=${encodeURIComponent(vehicle.owner.name)}`}
target="_blank"
className="inline-flex items-center px-3 py-1 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors"
>
<span className="ml-2">👁</span>
عرض تفاصيل المالك
</Link>
</Flex>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="bg-blue-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">اسم المالك</label>
<p className="text-lg font-semibold text-gray-900">{vehicle.owner.name}</p>
</div>
{vehicle.owner.phone && (
<div className="bg-blue-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">رقم الهاتف</label>
<a
href={`tel:${vehicle.owner.phone}`}
className="text-blue-600 hover:text-blue-800 font-medium"
dir="ltr"
>
📞 {vehicle.owner.phone}
</a>
</div>
)}
{vehicle.owner.email && (
<div className="bg-blue-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">البريد الإلكتروني</label>
<a
href={`mailto:${vehicle.owner.email}`}
className="text-blue-600 hover:text-blue-800 font-medium"
dir="ltr"
>
{vehicle.owner.email}
</a>
</div>
)}
{vehicle.owner.address && (
<div className="bg-blue-50 p-4 rounded-lg md:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-600 mb-1">العنوان</label>
<p className="text-gray-900">{vehicle.owner.address}</p>
</div>
)}
</div>
</div>
</div>
{/* Maintenance Status */}
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<Flex justify="between" align="center">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<span className="text-gray-600 text-xl ml-2">🔧</span>
حالة الصيانة
</h3>
<Link
to={`/maintenance-visits?vehicleId=${vehicle.id}`}
target="_blank"
className="inline-flex items-center px-3 py-1 text-sm font-medium text-green-600 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
>
<span className="ml-2">📋</span>
عرض جميع زيارات الصيانة
</Link>
</Flex>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-green-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">آخر زيارة صيانة</label>
<p className="text-gray-900 font-medium">
{vehicle.lastVisitDate
? formatDate(vehicle.lastVisitDate)
: <span className="text-gray-400">لا توجد زيارات</span>
}
</p>
</div>
{vehicle.suggestedNextVisitDate && (
<div className="bg-orange-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">الزيارة المقترحة التالية</label>
<p className="text-orange-600 font-bold">
{formatDate(vehicle.suggestedNextVisitDate)}
</p>
</div>
)}
<div className="bg-gray-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">تاريخ التسجيل</label>
<p className="text-gray-900">
{formatDate(vehicle.createdDate)}
</p>
</div>
<div className="bg-gray-50 p-4 rounded-lg">
<label className="block text-sm font-medium text-gray-600 mb-1">آخر تحديث</label>
<p className="text-gray-900">
{formatDate(vehicle.updateDate)}
</p>
</div>
</div>
</div>
</div>
{/* Recent Maintenance Visits
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<span className="text-gray-600 text-xl ml-2">🔧</span>
آخر زيارات الصيانة
</h3>
</div>
<div className="p-6">
{isLoadingVisits ? (
<div className="text-center py-12">
<div className="text-gray-400 text-4xl mb-4">🔧</div>
<h4 className="text-lg font-medium text-gray-900 mb-2">جاري تحميل زيارات الصيانة...</h4>
<div className="flex items-center justify-center">
<svg className="animate-spin h-6 w-6 text-gray-400" 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>
</div>
) : !('maintenanceVisits' in vehicle) || !vehicle.maintenanceVisits || vehicle.maintenanceVisits.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 text-4xl mb-4">🔧</div>
<h4 className="text-lg font-medium text-gray-900 mb-2">لا توجد زيارات صيانة</h4>
<p className="text-gray-500 mb-4">لم يتم تسجيل أي زيارات صيانة لهذه المركبة بعد</p>
<Link
to="/maintenance-visits"
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
>
تسجيل زيارة صيانة جديدة
</Link>
</div>
) : (
<div className="space-y-4">
{('maintenanceVisits' in vehicle ? vehicle.maintenanceVisits : []).slice(0, 3).map((visit: any) => (
<div
key={visit.id}
className="bg-gray-50 rounded-lg p-4 border border-gray-200 hover:border-green-300 hover:bg-green-50 transition-all"
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1">
<h4 className="font-semibold text-gray-900 text-lg">
{(() => {
try {
const jobs = JSON.parse(visit.maintenanceJobs);
return jobs.length > 1
? `${jobs.length} أعمال صيانة`
: jobs[0]?.job || 'نوع صيانة غير محدد';
} catch {
return 'نوع صيانة غير محدد';
}
})()}
</h4>
<p className="text-sm text-gray-500">زيارة #{visit.id}</p>
</div>
<div className="text-left">
<div className="text-lg font-bold text-green-600">
{formatCurrency(visit.cost)}
</div>
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${visit.paymentStatus === "paid"
? 'bg-green-100 text-green-800'
: visit.paymentStatus === 'pending'
? 'bg-yellow-100 text-yellow-800'
: 'bg-red-100 text-red-800'
}`}>
{visit.paymentStatus === 'paid' ? 'مدفوع' :
visit.paymentStatus === 'pending' ? 'معلق' : 'غير مدفوع'}
</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">تاريخ الزيارة:</span>
<span className="font-medium text-gray-900">
{formatDate(visit.visitDate)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">عداد الكيلومترات:</span>
<span className="font-medium text-gray-900">
{visit.kilometers ? formatNumber(visit.kilometers) : 'غير محدد'} كم
</span>
</div>
{visit.description && (
<div className="md:col-span-2">
<span className="text-gray-600">الوصف:</span>
<p className="text-gray-900 mt-1">{visit.description}</p>
</div>
)}
</div>
</div>
))}
{('maintenanceVisits' in vehicle ? vehicle.maintenanceVisits : []).length > 3 && (
<div className="text-center py-4 border-t border-gray-200">
<p className="text-sm text-gray-500 mb-3">
عرض 3 من أصل {('maintenanceVisits' in vehicle ? vehicle.maintenanceVisits : []).length} زيارة صيانة
</p>
<Link
to={`/maintenance-visits?vehicleId=${vehicle.id}`}
target="_blank"
className="inline-flex items-center px-4 py-2 text-sm font-medium text-green-600 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
>
<span className="ml-2">📋</span>
عرض جميع الزيارات ({('maintenanceVisits' in vehicle ? vehicle.maintenanceVisits : []).length})
</Link>
</div>
)}
</div>
)}
</div>
</div> */}
{/* Action Buttons */}
{(onEdit || onClose) && (
<div className="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-200">
{onEdit && (
<Button
onClick={onEdit}
className="bg-blue-600 hover:bg-blue-700 flex-1 sm:flex-none"
>
<span className="ml-2"></span>
تعديل المركبة
</Button>
)}
{onClose && (
<Button
variant="outline"
onClick={onClose}
className="flex-1 sm:flex-none"
>
إغلاق
</Button>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,576 @@
import { Form } from "@remix-run/react";
import { useState, useEffect } from "react";
import { Input } from "~/components/ui/Input";
import { AutocompleteInput } from "~/components/ui/AutocompleteInput";
import { Button } from "~/components/ui/Button";
import { Flex } from "~/components/layout/Flex";
import { TRANSMISSION_TYPES, FUEL_TYPES, USE_TYPES, VALIDATION } from "~/lib/constants";
import type { Vehicle } from "~/types/database";
interface VehicleFormProps {
vehicle?: Vehicle;
customers: { id: number; name: string; phone?: string | null }[];
onCancel: () => void;
errors?: Record<string, string>;
isLoading: boolean;
}
export function VehicleForm({
vehicle,
customers,
onCancel,
errors = {},
isLoading,
}: VehicleFormProps) {
const [formData, setFormData] = useState({
plateNumber: vehicle?.plateNumber || "",
bodyType: vehicle?.bodyType || "",
manufacturer: vehicle?.manufacturer || "",
model: vehicle?.model || "",
trim: vehicle?.trim || "",
year: vehicle?.year?.toString() || "",
transmission: vehicle?.transmission || "",
fuel: vehicle?.fuel || "",
cylinders: vehicle?.cylinders?.toString() || "",
engineDisplacement: vehicle?.engineDisplacement?.toString() || "",
useType: vehicle?.useType || "",
ownerId: vehicle?.ownerId?.toString() || "",
});
// Car dataset state
const [manufacturers, setManufacturers] = useState<string[]>([]);
const [models, setModels] = useState<{model: string; bodyType: string}[]>([]);
const [isLoadingManufacturers, setIsLoadingManufacturers] = useState(false);
const [isLoadingModels, setIsLoadingModels] = useState(false);
// Autocomplete state
const [manufacturerSearchValue, setManufacturerSearchValue] = useState(vehicle?.manufacturer || "");
const [modelSearchValue, setModelSearchValue] = useState(vehicle?.model || "");
const [ownerSearchValue, setOwnerSearchValue] = useState(() => {
if (vehicle?.ownerId) {
const owner = customers.find(c => c.id === vehicle.ownerId);
return owner ? owner.name : "";
}
return "";
});
// Load manufacturers on component mount
useEffect(() => {
const loadManufacturers = async () => {
setIsLoadingManufacturers(true);
try {
const response = await fetch('/api/car-dataset?action=manufacturers');
const result = await response.json();
if (result.success) {
setManufacturers(result.data);
}
} catch (error) {
console.error('Error loading manufacturers:', error);
} finally {
setIsLoadingManufacturers(false);
}
};
loadManufacturers();
}, []);
// Load models when manufacturer changes
useEffect(() => {
if (formData.manufacturer) {
const loadModels = async () => {
setIsLoadingModels(true);
try {
const response = await fetch(`/api/car-dataset?action=models&manufacturer=${encodeURIComponent(formData.manufacturer)}`);
const result = await response.json();
if (result.success) {
setModels(result.data);
}
} catch (error) {
console.error('Error loading models:', error);
} finally {
setIsLoadingModels(false);
}
};
loadModels();
} else {
setModels([]);
}
}, [formData.manufacturer]);
// Create autocomplete options
const manufacturerOptions = manufacturers.map(manufacturer => ({
value: manufacturer,
label: manufacturer,
data: manufacturer
}));
const modelOptions = models.map(item => ({
value: item.model,
label: item.model,
data: item
}));
const ownerOptions = customers.map(customer => ({
value: customer.name,
label: `${customer.name}${customer.phone ? ` - ${customer.phone}` : ''}`,
data: customer
}));
// Handle manufacturer selection
const handleManufacturerSelect = (option: any) => {
const manufacturer = option.data;
setManufacturerSearchValue(manufacturer);
setFormData(prev => ({
...prev,
manufacturer,
model: "", // Reset model when manufacturer changes
bodyType: "" // Reset body type when manufacturer changes
}));
setModelSearchValue(""); // Reset model search
};
// Handle model selection
const handleModelSelect = (option: any) => {
const modelData = option.data;
setModelSearchValue(modelData.model);
setFormData(prev => ({
...prev,
model: modelData.model,
bodyType: modelData.bodyType // Auto-set body type from dataset
}));
};
// Handle owner selection from autocomplete
const handleOwnerSelect = (option: any) => {
const customer = option.data;
setOwnerSearchValue(customer.name);
setFormData(prev => ({
...prev,
ownerId: customer.id.toString()
}));
};
// Reset form data when vehicle changes
useEffect(() => {
if (vehicle) {
const owner = customers.find(c => c.id === vehicle.ownerId);
setFormData({
plateNumber: vehicle.plateNumber || "",
bodyType: vehicle.bodyType || "",
manufacturer: vehicle.manufacturer || "",
model: vehicle.model || "",
trim: vehicle.trim || "",
year: vehicle.year?.toString() || "",
transmission: vehicle.transmission || "",
fuel: vehicle.fuel || "",
cylinders: vehicle.cylinders?.toString() || "",
engineDisplacement: vehicle.engineDisplacement?.toString() || "",
useType: vehicle.useType || "",
ownerId: vehicle.ownerId?.toString() || "",
});
setManufacturerSearchValue(vehicle.manufacturer || "");
setModelSearchValue(vehicle.model || "");
setOwnerSearchValue(owner ? owner.name : "");
} else {
setFormData({
plateNumber: "",
bodyType: "",
manufacturer: "",
model: "",
trim: "",
year: "",
transmission: "",
fuel: "",
cylinders: "",
engineDisplacement: "",
useType: "",
ownerId: "",
});
setManufacturerSearchValue("");
setModelSearchValue("");
setOwnerSearchValue("");
}
}, [vehicle, customers]);
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({
...prev,
[field]: value,
}));
};
const isEditing = !!vehicle;
const currentYear = new Date().getFullYear();
return (
<Form method="post" className="space-y-6">
<input
type="hidden"
name="_action"
value={isEditing ? "update" : "create"}
/>
{isEditing && (
<input type="hidden" name="id" value={vehicle.id} />
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Plate Number */}
<div>
<label htmlFor="plateNumber" className="block text-sm font-medium text-gray-700 mb-2">
رقم اللوحة *
</label>
<Input
id="plateNumber"
name="plateNumber"
type="text"
value={formData.plateNumber}
onChange={(e) => handleInputChange("plateNumber", e.target.value)}
placeholder="أدخل رقم اللوحة"
error={errors.plateNumber}
required
disabled={isLoading}
dir="ltr"
/>
{errors.plateNumber && (
<p className="mt-1 text-sm text-red-600">{errors.plateNumber}</p>
)}
</div>
{/* Manufacturer with Autocomplete */}
<div>
<AutocompleteInput
label="الشركة المصنعة *"
placeholder={isLoadingManufacturers ? "جاري التحميل..." : "ابدأ بكتابة اسم الشركة المصنعة..."}
value={manufacturerSearchValue}
onChange={setManufacturerSearchValue}
onSelect={handleManufacturerSelect}
options={manufacturerOptions}
error={errors.manufacturer}
required
disabled={isLoading || isLoadingManufacturers}
/>
{/* Hidden input for form submission */}
<input
type="hidden"
name="manufacturer"
value={formData.manufacturer}
/>
{formData.manufacturer && manufacturerSearchValue && (
<p className="mt-1 text-sm text-green-600">
تم اختيار الشركة المصنعة: {manufacturerSearchValue}
</p>
)}
</div>
{/* Model with Autocomplete */}
<div>
<AutocompleteInput
label="الموديل *"
placeholder={
!formData.manufacturer
? "اختر الشركة المصنعة أولاً"
: isLoadingModels
? "جاري التحميل..."
: "ابدأ بكتابة اسم الموديل..."
}
value={modelSearchValue}
onChange={setModelSearchValue}
onSelect={handleModelSelect}
options={modelOptions}
error={errors.model}
required
disabled={isLoading || isLoadingModels || !formData.manufacturer}
/>
{/* Hidden input for form submission */}
<input
type="hidden"
name="model"
value={formData.model}
/>
{formData.model && modelSearchValue && (
<p className="mt-1 text-sm text-green-600">
تم اختيار الموديل: {modelSearchValue}
</p>
)}
{!formData.manufacturer && (
<p className="mt-1 text-sm text-gray-500">
يرجى اختيار الشركة المصنعة أولاً
</p>
)}
</div>
{/* Body Type (Auto-filled, Read-only) */}
<div>
<label htmlFor="bodyType" className="block text-sm font-medium text-gray-700 mb-2">
نوع الهيكل *
</label>
<Input
id="bodyType"
name="bodyType"
type="text"
value={formData.bodyType}
placeholder={formData.model ? "سيتم تعبئته تلقائياً" : "اختر الموديل أولاً"}
error={errors.bodyType}
required
readOnly={true}
className="bg-gray-50"
/>
{formData.bodyType && (
<p className="mt-1 text-sm text-blue-600">
تم تعبئة نوع الهيكل تلقائياً من قاعدة البيانات
</p>
)}
{errors.bodyType && (
<p className="mt-1 text-sm text-red-600">{errors.bodyType}</p>
)}
</div>
{/* Trim */}
<div>
<label htmlFor="trim" className="block text-sm font-medium text-gray-700 mb-2">
الفئة
</label>
<Input
id="trim"
name="trim"
type="text"
value={formData.trim}
onChange={(e) => handleInputChange("trim", e.target.value)}
placeholder="أدخل الفئة (اختياري)"
error={errors.trim}
disabled={isLoading}
/>
{errors.trim && (
<p className="mt-1 text-sm text-red-600">{errors.trim}</p>
)}
</div>
{/* Year */}
<div>
<label htmlFor="year" className="block text-sm font-medium text-gray-700 mb-2">
سنة الصنع *
</label>
<Input
id="year"
name="year"
type="number"
min={VALIDATION.MIN_YEAR}
max={VALIDATION.MAX_YEAR}
value={formData.year}
onChange={(e) => handleInputChange("year", e.target.value)}
placeholder={`${VALIDATION.MIN_YEAR} - ${currentYear}`}
error={errors.year}
required
disabled={isLoading}
/>
{errors.year && (
<p className="mt-1 text-sm text-red-600">{errors.year}</p>
)}
</div>
{/* Transmission */}
<div>
<label htmlFor="transmission" className="block text-sm font-medium text-gray-700 mb-2">
ناقل الحركة *
</label>
<select
id="transmission"
name="transmission"
value={formData.transmission}
onChange={(e) => handleInputChange("transmission", e.target.value)}
required
disabled={isLoading}
className={`
w-full px-3 py-2 border rounded-lg shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:bg-gray-50 disabled:text-gray-500
${errors.transmission
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300'
}
`}
>
<option value="">اختر ناقل الحركة</option>
{TRANSMISSION_TYPES.map((transmission) => (
<option key={transmission.value} value={transmission.value}>
{transmission.label}
</option>
))}
</select>
{errors.transmission && (
<p className="mt-1 text-sm text-red-600">{errors.transmission}</p>
)}
</div>
{/* Fuel */}
<div>
<label htmlFor="fuel" className="block text-sm font-medium text-gray-700 mb-2">
نوع الوقود *
</label>
<select
id="fuel"
name="fuel"
value={formData.fuel}
onChange={(e) => handleInputChange("fuel", e.target.value)}
required
disabled={isLoading}
className={`
w-full px-3 py-2 border rounded-lg shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:bg-gray-50 disabled:text-gray-500
${errors.fuel
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300'
}
`}
>
<option value="">اختر نوع الوقود</option>
{FUEL_TYPES.map((fuel) => (
<option key={fuel.value} value={fuel.value}>
{fuel.label}
</option>
))}
</select>
{errors.fuel && (
<p className="mt-1 text-sm text-red-600">{errors.fuel}</p>
)}
</div>
{/* Cylinders */}
<div>
<label htmlFor="cylinders" className="block text-sm font-medium text-gray-700 mb-2">
عدد الأسطوانات
</label>
<Input
id="cylinders"
name="cylinders"
type="number"
min="1"
max={VALIDATION.MAX_CYLINDERS}
value={formData.cylinders}
onChange={(e) => handleInputChange("cylinders", e.target.value)}
placeholder="عدد الأسطوانات (اختياري)"
error={errors.cylinders}
disabled={isLoading}
/>
{errors.cylinders && (
<p className="mt-1 text-sm text-red-600">{errors.cylinders}</p>
)}
</div>
{/* Engine Displacement */}
<div>
<label htmlFor="engineDisplacement" className="block text-sm font-medium text-gray-700 mb-2">
سعة المحرك (لتر)
</label>
<Input
id="engineDisplacement"
name="engineDisplacement"
type="number"
step="0.1"
min="0.1"
max={VALIDATION.MAX_ENGINE_DISPLACEMENT}
value={formData.engineDisplacement}
onChange={(e) => handleInputChange("engineDisplacement", e.target.value)}
placeholder="سعة المحرك (اختياري)"
error={errors.engineDisplacement}
disabled={isLoading}
/>
{errors.engineDisplacement && (
<p className="mt-1 text-sm text-red-600">{errors.engineDisplacement}</p>
)}
</div>
{/* Use Type */}
<div>
<label htmlFor="useType" className="block text-sm font-medium text-gray-700 mb-2">
نوع الاستخدام *
</label>
<select
id="useType"
name="useType"
value={formData.useType}
onChange={(e) => handleInputChange("useType", e.target.value)}
required
disabled={isLoading}
className={`
w-full px-3 py-2 border rounded-lg shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:bg-gray-50 disabled:text-gray-500
${errors.useType
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
: 'border-gray-300'
}
`}
>
<option value="">اختر نوع الاستخدام</option>
{USE_TYPES.map((useType) => (
<option key={useType.value} value={useType.value}>
{useType.label}
</option>
))}
</select>
{errors.useType && (
<p className="mt-1 text-sm text-red-600">{errors.useType}</p>
)}
</div>
{/* Owner with Autocomplete */}
<div>
<AutocompleteInput
label="المالك *"
placeholder="ابدأ بكتابة اسم المالك..."
value={ownerSearchValue}
onChange={setOwnerSearchValue}
onSelect={handleOwnerSelect}
options={ownerOptions}
error={errors.ownerId}
required
disabled={isLoading}
/>
{/* Hidden input for form submission */}
<input
type="hidden"
name="ownerId"
value={formData.ownerId}
/>
{formData.ownerId && ownerSearchValue && (
<p className="mt-1 text-sm text-green-600">
تم اختيار المالك: {ownerSearchValue}
</p>
)}
{!formData.ownerId && ownerSearchValue && (
<p className="mt-1 text-sm text-amber-600">
يرجى اختيار المالك من القائمة المنسدلة
</p>
)}
</div>
</div>
{/* Form Actions */}
<Flex justify="end" className="pt-4 gap-2 border-t">
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isLoading}
className="w-20"
>
إلغاء
</Button>
<Button
type="submit"
disabled={isLoading || !formData.plateNumber.trim() || !formData.ownerId}
className="bg-blue-600 hover:bg-blue-700"
>
{isLoading
? (isEditing ? "جاري التحديث..." : "جاري الإنشاء...")
: (isEditing ? "تحديث المركبة" : "إنشاء المركبة")
}
</Button>
</Flex>
</Form>
);
}

View File

@@ -0,0 +1,272 @@
import { Form, Link } from "@remix-run/react";
import { useState, useEffect } from "react";
import { DataTable, Pagination } from "~/components/ui/DataTable";
import { Button } from "~/components/ui/Button";
import { Flex } from "~/components/layout/Flex";
import { useSettings } from "~/contexts/SettingsContext";
import { TRANSMISSION_TYPES, FUEL_TYPES, USE_TYPES, BODY_TYPES } from "~/lib/constants";
import type { VehicleWithOwner } from "~/types/database";
interface VehicleListProps {
vehicles: VehicleWithOwner[];
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
onEditVehicle: (vehicle: VehicleWithOwner) => void;
onViewVehicle?: (vehicle: VehicleWithOwner) => void;
isLoading: boolean;
actionData?: any;
}
export function VehicleList({
vehicles,
currentPage,
totalPages,
onPageChange,
onEditVehicle,
onViewVehicle,
isLoading,
actionData,
}: VehicleListProps) {
const { formatDate } = useSettings();
const [deletingVehicleId, setDeletingVehicleId] = useState<number | null>(null);
// Reset deleting state when delete action completes
useEffect(() => {
if (actionData?.success && actionData.action === "delete") {
setDeletingVehicleId(null);
}
}, [actionData]);
// Helper functions to get display labels
const getTransmissionLabel = (value: string) => {
return TRANSMISSION_TYPES.find(t => t.value === value)?.label || value;
};
const getFuelLabel = (value: string) => {
return FUEL_TYPES.find(f => f.value === value)?.label || value;
};
const getUseTypeLabel = (value: string) => {
return USE_TYPES.find(u => u.value === value)?.label || value;
};
const getBodyTypeLabel = (value: string) => {
return BODY_TYPES.find(b => b.value === value)?.label || value;
};
const columns = [
{
key: "plateNumber",
header: "رقم اللوحة",
render: (vehicle: VehicleWithOwner) => (
<div>
<Link
to={`/vehicles/${vehicle.id}`}
className="font-mono text-lg font-medium text-blue-600 hover:text-blue-800"
>
{vehicle.plateNumber}
</Link>
<div className="text-sm text-gray-500">
المركبة رقم: {vehicle.id}
</div>
</div>
),
},
{
key: "vehicle",
header: "تفاصيل المركبة",
render: (vehicle: VehicleWithOwner) => (
<div>
<div className="font-medium text-gray-900">
{vehicle.manufacturer} {vehicle.model}
</div>
<div className="text-sm text-gray-600">
{vehicle.year} {getBodyTypeLabel(vehicle.bodyType)}
</div>
{vehicle.trim && (
<div className="text-sm text-gray-500">
فئة: {vehicle.trim}
</div>
)}
</div>
),
},
{
key: "specifications",
header: "المواصفات",
render: (vehicle: VehicleWithOwner) => (
<div className="space-y-1">
<div className="text-sm text-gray-900">
{getTransmissionLabel(vehicle.transmission)}
</div>
<div className="text-sm text-gray-600">
{getFuelLabel(vehicle.fuel)}
</div>
{vehicle.cylinders && (
<div className="text-sm text-gray-500">
{vehicle.cylinders} أسطوانة
</div>
)}
{vehicle.engineDisplacement && (
<div className="text-sm text-gray-500">
{vehicle.engineDisplacement}L
</div>
)}
</div>
),
},
{
key: "owner",
header: "المالك",
render: (vehicle: VehicleWithOwner) => (
<div>
<Link
to={`/customers/${vehicle.owner.id}`}
className="font-medium text-blue-600 hover:text-blue-800"
>
{vehicle.owner.name}
</Link>
{vehicle.owner.phone && (
<div className="text-sm text-gray-500" dir="ltr">
{vehicle.owner.phone}
</div>
)}
</div>
),
},
{
key: "useType",
header: "نوع الاستخدام",
render: (vehicle: VehicleWithOwner) => (
<div className="text-sm text-gray-900">
{getUseTypeLabel(vehicle.useType)}
</div>
),
},
{
key: "maintenance",
header: "الصيانة",
render: (vehicle: VehicleWithOwner) => (
<div className="space-y-1">
{vehicle.lastVisitDate ? (
<div className="text-sm text-gray-900">
آخر زيارة: {formatDate(vehicle.lastVisitDate)}
</div>
) : (
<div className="text-sm text-gray-400">
لا توجد زيارات
</div>
)}
{vehicle.suggestedNextVisitDate && (
<div className="text-sm text-orange-600">
الزيارة التالية: {formatDate(vehicle.suggestedNextVisitDate)}
</div>
)}
</div>
),
},
{
key: "createdDate",
header: "تاريخ التسجيل",
render: (vehicle: VehicleWithOwner) => (
<div className="text-sm text-gray-600">
{formatDate(vehicle.createdDate)}
</div>
),
},
{
key: "actions",
header: "الإجراءات",
render: (vehicle: VehicleWithOwner) => (
<Flex className="flex-wrap gap-2">
{onViewVehicle ? (
<Button
size="sm"
variant="outline"
onClick={() => onViewVehicle(vehicle)}
disabled={isLoading}
>
عرض
</Button>
) : (
<Link to={`/vehicles/${vehicle.id}`}>
<Button
size="sm"
variant="outline"
disabled={isLoading}
>
عرض
</Button>
</Link>
)}
<Button
size="sm"
variant="outline"
onClick={() => onEditVehicle(vehicle)}
disabled={isLoading}
>
تعديل
</Button>
<Form method="post" className="inline">
<input type="hidden" name="_action" value="delete" />
<input type="hidden" name="id" value={vehicle.id} />
<Button
type="submit"
size="sm"
variant="outline"
className="text-red-600 border-red-300 hover:bg-red-50"
disabled={isLoading || deletingVehicleId === vehicle.id}
onClick={(e) => {
e.preventDefault();
if (window.confirm("هل أنت متأكد من حذف هذه المركبة؟")) {
setDeletingVehicleId(vehicle.id);
(e.target as HTMLButtonElement).form?.submit();
}
}}
>
{deletingVehicleId === vehicle.id ? "جاري الحذف..." : "حذف"}
</Button>
</Form>
</Flex>
),
},
];
return (
<div className="space-y-4">
<div className="bg-white rounded-lg shadow-sm border">
{vehicles.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 text-lg mb-2">🚗</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
لا توجد مركبات
</h3>
<p className="text-gray-500">
لم يتم العثور على أي مركبات. قم بإضافة مركبة جديدة للبدء.
</p>
</div>
) : (
<DataTable
data={vehicles}
columns={columns}
loading={isLoading}
emptyMessage="لم يتم العثور على أي مركبات"
/>
)}
</div>
{totalPages > 1 && (
<div className="flex justify-center">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={onPageChange}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,163 @@
import { createContext, useContext, type ReactNode } from 'react';
import type { AppSettings } from '~/lib/settings-management.server';
interface SettingsContextType {
settings: AppSettings;
formatNumber: (value: number) => string;
formatCurrency: (value: number) => string;
formatDate: (date: Date | string) => string;
formatDateTime: (date: Date | string) => string;
}
const SettingsContext = createContext<SettingsContextType | null>(null);
interface SettingsProviderProps {
children: ReactNode;
settings: AppSettings;
}
export function SettingsProvider({ children, settings }: SettingsProviderProps) {
// Helper function to convert Western numerals to Arabic numerals
const convertToArabicNumerals = (str: string): string => {
const arabicNumerals = ['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩'];
return str.replace(/[0-9]/g, (digit) => arabicNumerals[parseInt(digit)]);
};
// Helper function to format date with custom pattern
const formatDateWithPattern = (date: Date, pattern: string): string => {
const day = date.getDate();
const month = date.getMonth() + 1;
const year = date.getFullYear();
// Format numbers according to locale
const formatNumber = (num: number, padLength: number = 2): string => {
const padded = num.toString().padStart(padLength, '0');
return settings.numberFormat === 'ar-SA'
? convertToArabicNumerals(padded)
: padded;
};
return pattern
.replace(/yyyy/g, formatNumber(year, 4))
.replace(/yy/g, formatNumber(year % 100, 2))
.replace(/MM/g, formatNumber(month, 2))
.replace(/M/g, formatNumber(month, 1))
.replace(/dd/g, formatNumber(day, 2))
.replace(/d/g, formatNumber(day, 1));
};
const formatNumber = (value: number): string => {
return value.toLocaleString(settings.numberFormat);
};
const formatCurrency = (value: number): string => {
const formatted = value.toLocaleString(settings.numberFormat, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
return `${formatted} ${settings.currencySymbol}`;
};
const formatDate = (date: Date | string): string => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return formatDateWithPattern(dateObj, settings.dateDisplayFormat);
};
const formatDateTime = (date: Date | string): string => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
const datePart = formatDateWithPattern(dateObj, settings.dateDisplayFormat);
const timePart = dateObj.toLocaleTimeString(settings.dateFormat, {
hour: '2-digit',
minute: '2-digit'
});
return `${datePart} ${timePart}`;
};
const contextValue: SettingsContextType = {
settings,
formatNumber,
formatCurrency,
formatDate,
formatDateTime
};
return (
<SettingsContext.Provider value={contextValue}>
{children}
</SettingsContext.Provider>
);
}
export function useSettings(): SettingsContextType {
const context = useContext(SettingsContext);
if (!context) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
}
// Hook for formatting utilities without requiring context
export function useFormatters(settings: AppSettings) {
// Helper function to convert Western numerals to Arabic numerals
const convertToArabicNumerals = (str: string): string => {
const arabicNumerals = ['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩'];
return str.replace(/[0-9]/g, (digit) => arabicNumerals[parseInt(digit)]);
};
// Helper function to format date with custom pattern
const formatDateWithPattern = (date: Date, pattern: string): string => {
const day = date.getDate();
const month = date.getMonth() + 1;
const year = date.getFullYear();
// Format numbers according to locale
const formatNumber = (num: number, padLength: number = 2): string => {
const padded = num.toString().padStart(padLength, '0');
return settings.numberFormat === 'ar-SA'
? convertToArabicNumerals(padded)
: padded;
};
return pattern
.replace(/yyyy/g, formatNumber(year, 4))
.replace(/yy/g, formatNumber(year % 100, 2))
.replace(/MM/g, formatNumber(month, 2))
.replace(/M/g, formatNumber(month, 1))
.replace(/dd/g, formatNumber(day, 2))
.replace(/d/g, formatNumber(day, 1));
};
const formatNumber = (value: number): string => {
return value.toLocaleString(settings.numberFormat);
};
const formatCurrency = (value: number): string => {
const formatted = value.toLocaleString(settings.numberFormat, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
return `${formatted} ${settings.currencySymbol}`;
};
const formatDate = (date: Date | string): string => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return formatDateWithPattern(dateObj, settings.dateDisplayFormat);
};
const formatDateTime = (date: Date | string): string => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
const datePart = formatDateWithPattern(dateObj, settings.dateDisplayFormat);
const timePart = dateObj.toLocaleTimeString(settings.dateFormat, {
hour: '2-digit',
minute: '2-digit'
});
return `${datePart} ${timePart}`;
};
return {
formatNumber,
formatCurrency,
formatDate,
formatDateTime
};
}

18
app/entry.client.tsx Normal file
View File

@@ -0,0 +1,18 @@
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/file-conventions/entry.client
*/
import { RemixBrowser } from "@remix-run/react";
import { startTransition, StrictMode } from "react";
import { hydrateRoot } from "react-dom/client";
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser />
</StrictMode>
);
});

140
app/entry.server.tsx Normal file
View File

@@ -0,0 +1,140 @@
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/file-conventions/entry.server
*/
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import { isbot } from "isbot";
import { renderToPipeableStream } from "react-dom/server";
const ABORT_DELAY = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
// This is ignored so we can keep it in the template for visibility. Feel
// free to delete this parameter in your app if you're not using it!
// eslint-disable-next-line @typescript-eslint/no-unused-vars
loadContext: AppLoadContext
) {
return isbot(request.headers.get("user-agent") || "")
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
);
}
function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onAllReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}
function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}

17
app/hooks/useDebounce.ts Normal file
View File

@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,219 @@
import { useState, useCallback, useEffect } from 'react';
import { z } from 'zod';
interface UseFormValidationOptions<T> {
schema: z.ZodSchema<T>;
initialValues: Partial<T>;
validateOnChange?: boolean;
validateOnBlur?: boolean;
}
interface FormState<T> {
values: Partial<T>;
errors: Record<string, string>;
touched: Record<string, boolean>;
isValid: boolean;
isSubmitting: boolean;
}
export function useFormValidation<T extends Record<string, any>>({
schema,
initialValues,
validateOnChange = true,
validateOnBlur = true,
}: UseFormValidationOptions<T>) {
const [state, setState] = useState<FormState<T>>({
values: initialValues,
errors: {},
touched: {},
isValid: false,
isSubmitting: false,
});
// Validate a single field
const validateField = useCallback((name: keyof T, value: any): string | null => {
try {
// Get the field schema
const fieldSchema = (schema as any).shape[name];
if (fieldSchema) {
fieldSchema.parse(value);
}
return null;
} catch (error) {
if (error instanceof z.ZodError) {
return error.errors[0]?.message || null;
}
return null;
}
}, [schema]);
// Validate all fields
const validateForm = useCallback((values: Partial<T>): Record<string, string> => {
try {
schema.parse(values);
return {};
} catch (error) {
if (error instanceof z.ZodError) {
const errors: Record<string, string> = {};
error.errors.forEach((err) => {
if (err.path.length > 0) {
errors[err.path[0] as string] = err.message;
}
});
return errors;
}
return {};
}
}, [schema]);
// Set field value
const setValue = useCallback((name: keyof T, value: any) => {
setState(prev => {
const newValues = { ...prev.values, [name]: value };
const fieldError = validateOnChange ? validateField(name, value) : null;
const newErrors = { ...prev.errors };
if (fieldError) {
newErrors[name as string] = fieldError;
} else {
delete newErrors[name as string];
}
const allErrors = validateOnChange ? validateForm(newValues) : newErrors;
const isValid = Object.keys(allErrors).length === 0;
return {
...prev,
values: newValues,
errors: allErrors,
isValid,
};
});
}, [validateField, validateForm, validateOnChange]);
// Set field as touched
const setTouched = useCallback((name: keyof T, touched = true) => {
setState(prev => {
const newTouched = { ...prev.touched, [name]: touched };
let newErrors = { ...prev.errors };
if (touched && validateOnBlur) {
const fieldError = validateField(name, prev.values[name]);
if (fieldError) {
newErrors[name as string] = fieldError;
}
}
return {
...prev,
touched: newTouched,
errors: newErrors,
};
});
}, [validateField, validateOnBlur]);
// Set multiple values
const setValues = useCallback((values: Partial<T>) => {
setState(prev => {
const newValues = { ...prev.values, ...values };
const errors = validateForm(newValues);
const isValid = Object.keys(errors).length === 0;
return {
...prev,
values: newValues,
errors,
isValid,
};
});
}, [validateForm]);
// Reset form
const reset = useCallback((newInitialValues?: Partial<T>) => {
const resetValues = newInitialValues || initialValues;
setState({
values: resetValues,
errors: {},
touched: {},
isValid: false,
isSubmitting: false,
});
}, [initialValues]);
// Set submitting state
const setSubmitting = useCallback((isSubmitting: boolean) => {
setState(prev => ({ ...prev, isSubmitting }));
}, []);
// Validate entire form and return validation result
const validate = useCallback(() => {
const errors = validateForm(state.values);
const isValid = Object.keys(errors).length === 0;
setState(prev => ({
...prev,
errors,
isValid,
touched: Object.keys(prev.values).reduce((acc, key) => {
acc[key] = true;
return acc;
}, {} as Record<string, boolean>),
}));
return { isValid, errors };
}, [state.values, validateForm]);
// Get field props for easy integration with form components
const getFieldProps = useCallback((name: keyof T) => {
return {
name: name as string,
value: state.values[name] || '',
error: state.touched[name as string] ? state.errors[name as string] : undefined,
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
setValue(name, e.target.value);
},
onBlur: () => {
setTouched(name, true);
},
};
}, [state.values, state.errors, state.touched, setValue, setTouched]);
// Get field error
const getFieldError = useCallback((name: keyof T): string | undefined => {
return state.touched[name as string] ? state.errors[name as string] : undefined;
}, [state.errors, state.touched]);
// Check if field has error
const hasFieldError = useCallback((name: keyof T): boolean => {
return !!(state.touched[name as string] && state.errors[name as string]);
}, [state.errors, state.touched]);
// Update validation when schema or initial values change
useEffect(() => {
const errors = validateForm(state.values);
const isValid = Object.keys(errors).length === 0;
setState(prev => ({
...prev,
errors,
isValid,
}));
}, [schema, validateForm]);
return {
values: state.values,
errors: state.errors,
touched: state.touched,
isValid: state.isValid,
isSubmitting: state.isSubmitting,
setValue,
setValues,
setTouched,
setSubmitting,
reset,
validate,
getFieldProps,
getFieldError,
hasFieldError,
};
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import bcrypt from "bcryptjs";
// Test the actual bcrypt functionality without importing our modules
describe("Authentication Integration", () => {
describe("Password Hashing Integration", () => {
it("should hash and verify passwords using bcrypt", async () => {
const password = "testpassword123";
// Hash the password
const hashedPassword = await bcrypt.hash(password, 12);
expect(hashedPassword).toBeDefined();
expect(hashedPassword).not.toBe(password);
expect(hashedPassword.length).toBeGreaterThan(0);
// Verify correct password
const isValidPassword = await bcrypt.compare(password, hashedPassword);
expect(isValidPassword).toBe(true);
// Verify incorrect password
const isInvalidPassword = await bcrypt.compare("wrongpassword", hashedPassword);
expect(isInvalidPassword).toBe(false);
});
it("should generate different hashes for the same password", async () => {
const password = "testpassword123";
const hash1 = await bcrypt.hash(password, 12);
const hash2 = await bcrypt.hash(password, 12);
expect(hash1).not.toBe(hash2);
// But both should verify correctly
expect(await bcrypt.compare(password, hash1)).toBe(true);
expect(await bcrypt.compare(password, hash2)).toBe(true);
});
});
});

View File

@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import { hasPermission, canAccessUserManagement } from "../auth-helpers.server";
import { AUTH_LEVELS } from "~/types/auth";
// Mock the database
vi.mock("../db.server", () => ({
prisma: {
user: {
findFirst: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
count: vi.fn(),
},
},
}));
// Mock auth.server to avoid session secret requirement
vi.mock("../auth.server", () => ({
hashPassword: vi.fn(),
verifyPassword: vi.fn(),
createUserSession: vi.fn(),
getUserSession: vi.fn(),
getUserId: vi.fn(),
requireUserId: vi.fn(),
getUser: vi.fn(),
requireUser: vi.fn(),
logout: vi.fn(),
}));
describe("Authentication System", () => {
describe("Authorization Helpers", () => {
it("should check permissions correctly", () => {
// Superadmin should have access to everything
expect(hasPermission(AUTH_LEVELS.SUPERADMIN, AUTH_LEVELS.SUPERADMIN)).toBe(true);
expect(hasPermission(AUTH_LEVELS.SUPERADMIN, AUTH_LEVELS.ADMIN)).toBe(true);
expect(hasPermission(AUTH_LEVELS.SUPERADMIN, AUTH_LEVELS.USER)).toBe(true);
// Admin should have access to admin and user levels
expect(hasPermission(AUTH_LEVELS.ADMIN, AUTH_LEVELS.SUPERADMIN)).toBe(false);
expect(hasPermission(AUTH_LEVELS.ADMIN, AUTH_LEVELS.ADMIN)).toBe(true);
expect(hasPermission(AUTH_LEVELS.ADMIN, AUTH_LEVELS.USER)).toBe(true);
// User should only have access to user level
expect(hasPermission(AUTH_LEVELS.USER, AUTH_LEVELS.SUPERADMIN)).toBe(false);
expect(hasPermission(AUTH_LEVELS.USER, AUTH_LEVELS.ADMIN)).toBe(false);
expect(hasPermission(AUTH_LEVELS.USER, AUTH_LEVELS.USER)).toBe(true);
});
it("should check user management access correctly", () => {
expect(canAccessUserManagement(AUTH_LEVELS.SUPERADMIN)).toBe(true);
expect(canAccessUserManagement(AUTH_LEVELS.ADMIN)).toBe(true);
expect(canAccessUserManagement(AUTH_LEVELS.USER)).toBe(false);
});
});
});

View File

@@ -0,0 +1,437 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
// Mock Prisma first
vi.mock('../db.server', () => ({
prisma: {
customer: {
findMany: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
},
}));
import {
getCustomers,
createCustomer,
updateCustomer,
deleteCustomer,
getCustomerById,
getCustomersForSelect,
searchCustomers,
getCustomerStats
} from '../customer-management.server';
import { prisma } from '../db.server';
const mockPrisma = prisma as any;
describe('Customer Management Server', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getCustomers', () => {
it('should return customers with pagination', async () => {
const mockCustomers = [
{
id: 1,
name: 'أحمد محمد',
phone: '0501234567',
email: 'ahmed@example.com',
address: 'الرياض',
createdDate: new Date(),
updateDate: new Date(),
vehicles: [],
maintenanceVisits: [],
},
];
mockPrisma.customer.findMany.mockResolvedValue(mockCustomers);
mockPrisma.customer.count.mockResolvedValue(1);
const result = await getCustomers('', 1, 10);
expect(result).toEqual({
customers: mockCustomers,
total: 1,
totalPages: 1,
});
expect(mockPrisma.customer.findMany).toHaveBeenCalledWith({
where: {},
include: {
vehicles: {
select: {
id: true,
plateNumber: true,
manufacturer: true,
model: true,
year: true,
lastVisitDate: true,
suggestedNextVisitDate: true,
},
},
maintenanceVisits: {
select: {
id: true,
visitDate: true,
cost: true,
maintenanceType: true,
},
orderBy: { visitDate: 'desc' },
take: 5,
},
},
orderBy: { createdDate: 'desc' },
skip: 0,
take: 10,
});
});
it('should handle search query', async () => {
mockPrisma.customer.findMany.mockResolvedValue([]);
mockPrisma.customer.count.mockResolvedValue(0);
await getCustomers('أحمد', 1, 10);
expect(mockPrisma.customer.findMany).toHaveBeenCalledWith({
where: {
OR: [
{ name: { contains: 'أحمد' } },
{ phone: { contains: 'أحمد' } },
{ email: { contains: 'أحمد' } },
{ address: { contains: 'أحمد' } },
],
},
include: expect.any(Object),
orderBy: { createdDate: 'desc' },
skip: 0,
take: 10,
});
});
});
describe('createCustomer', () => {
it('should create a new customer successfully', async () => {
const customerData = {
name: 'أحمد محمد',
phone: '0501234567',
email: 'ahmed@example.com',
address: 'الرياض',
};
const mockCustomer = {
id: 1,
...customerData,
createdDate: new Date(),
updateDate: new Date(),
};
mockPrisma.customer.findFirst.mockResolvedValue(null);
mockPrisma.customer.create.mockResolvedValue(mockCustomer);
const result = await createCustomer(customerData);
expect(result).toEqual({
success: true,
customer: mockCustomer,
});
expect(mockPrisma.customer.create).toHaveBeenCalledWith({
data: {
name: 'أحمد محمد',
phone: '0501234567',
email: 'ahmed@example.com',
address: 'الرياض',
},
});
});
it('should return error if phone already exists', async () => {
const customerData = {
name: 'أحمد محمد',
phone: '0501234567',
};
const existingCustomer = {
id: 2,
name: 'محمد أحمد',
phone: '0501234567',
email: null,
address: null,
createdDate: new Date(),
updateDate: new Date(),
};
mockPrisma.customer.findFirst.mockResolvedValue(existingCustomer);
const result = await createCustomer(customerData);
expect(result).toEqual({
success: false,
error: 'رقم الهاتف موجود بالفعل',
});
expect(mockPrisma.customer.create).not.toHaveBeenCalled();
});
it('should return error if email already exists', async () => {
const customerData = {
name: 'أحمد محمد',
email: 'ahmed@example.com',
};
const existingCustomer = {
id: 2,
name: 'محمد أحمد',
phone: null,
email: 'ahmed@example.com',
address: null,
createdDate: new Date(),
updateDate: new Date(),
};
mockPrisma.customer.findFirst.mockResolvedValue(existingCustomer);
const result = await createCustomer(customerData);
expect(result).toEqual({
success: false,
error: 'البريد الإلكتروني موجود بالفعل',
});
expect(mockPrisma.customer.create).not.toHaveBeenCalled();
});
});
describe('updateCustomer', () => {
it('should update customer successfully', async () => {
const customerId = 1;
const updateData = {
name: 'أحمد محمد المحدث',
phone: '0509876543',
};
const existingCustomer = {
id: customerId,
name: 'أحمد محمد',
phone: '0501234567',
email: null,
address: null,
createdDate: new Date(),
updateDate: new Date(),
};
const updatedCustomer = {
...existingCustomer,
...updateData,
updateDate: new Date(),
};
mockPrisma.customer.findUnique.mockResolvedValue(existingCustomer);
mockPrisma.customer.findFirst.mockResolvedValue(null);
mockPrisma.customer.update.mockResolvedValue(updatedCustomer);
const result = await updateCustomer(customerId, updateData);
expect(result).toEqual({
success: true,
customer: updatedCustomer,
});
expect(mockPrisma.customer.update).toHaveBeenCalledWith({
where: { id: customerId },
data: {
name: 'أحمد محمد المحدث',
phone: '0509876543',
},
});
});
it('should return error if customer not found', async () => {
const customerId = 999;
const updateData = { name: 'أحمد محمد' };
mockPrisma.customer.findUnique.mockResolvedValue(null);
const result = await updateCustomer(customerId, updateData);
expect(result).toEqual({
success: false,
error: 'العميل غير موجود',
});
expect(mockPrisma.customer.update).not.toHaveBeenCalled();
});
});
describe('deleteCustomer', () => {
it('should delete customer successfully when no relationships exist', async () => {
const customerId = 1;
const existingCustomer = {
id: customerId,
name: 'أحمد محمد',
phone: '0501234567',
email: null,
address: null,
createdDate: new Date(),
updateDate: new Date(),
vehicles: [],
maintenanceVisits: [],
};
mockPrisma.customer.findUnique.mockResolvedValue(existingCustomer);
mockPrisma.customer.delete.mockResolvedValue(existingCustomer);
const result = await deleteCustomer(customerId);
expect(result).toEqual({
success: true,
});
expect(mockPrisma.customer.delete).toHaveBeenCalledWith({
where: { id: customerId },
});
});
it('should return error if customer has vehicles', async () => {
const customerId = 1;
const existingCustomer = {
id: customerId,
name: 'أحمد محمد',
phone: '0501234567',
email: null,
address: null,
createdDate: new Date(),
updateDate: new Date(),
vehicles: [{ id: 1, plateNumber: 'ABC-123' }],
maintenanceVisits: [],
};
mockPrisma.customer.findUnique.mockResolvedValue(existingCustomer);
const result = await deleteCustomer(customerId);
expect(result).toEqual({
success: false,
error: 'لا يمكن حذف العميل لأنه يملك 1 مركبة. يرجى حذف المركبات أولاً',
});
expect(mockPrisma.customer.delete).not.toHaveBeenCalled();
});
it('should return error if customer has maintenance visits', async () => {
const customerId = 1;
const existingCustomer = {
id: customerId,
name: 'أحمد محمد',
phone: '0501234567',
email: null,
address: null,
createdDate: new Date(),
updateDate: new Date(),
vehicles: [],
maintenanceVisits: [{ id: 1, visitDate: new Date() }],
};
mockPrisma.customer.findUnique.mockResolvedValue(existingCustomer);
const result = await deleteCustomer(customerId);
expect(result).toEqual({
success: false,
error: 'لا يمكن حذف العميل لأنه لديه 1 زيارة صيانة. يرجى حذف الزيارات أولاً',
});
expect(mockPrisma.customer.delete).not.toHaveBeenCalled();
});
it('should return error if customer not found', async () => {
const customerId = 999;
mockPrisma.customer.findUnique.mockResolvedValue(null);
const result = await deleteCustomer(customerId);
expect(result).toEqual({
success: false,
error: 'العميل غير موجود',
});
expect(mockPrisma.customer.delete).not.toHaveBeenCalled();
});
});
describe('searchCustomers', () => {
it('should return empty array for short queries', async () => {
const result = await searchCustomers('a');
expect(result).toEqual([]);
expect(mockPrisma.customer.findMany).not.toHaveBeenCalled();
});
it('should search customers by name, phone, and email', async () => {
const mockCustomers = [
{
id: 1,
name: 'أحمد محمد',
phone: '0501234567',
email: 'ahmed@example.com',
},
];
mockPrisma.customer.findMany.mockResolvedValue(mockCustomers);
const result = await searchCustomers('أحمد', 10);
expect(result).toEqual(mockCustomers);
expect(mockPrisma.customer.findMany).toHaveBeenCalledWith({
where: {
OR: [
{ name: { contains: 'أحمد' } },
{ phone: { contains: 'أحمد' } },
{ email: { contains: 'أحمد' } },
],
},
select: {
id: true,
name: true,
phone: true,
email: true,
},
orderBy: { name: 'asc' },
take: 10,
});
});
});
describe('getCustomerStats', () => {
it('should return customer statistics', async () => {
const customerId = 1;
const mockCustomer = {
id: customerId,
name: 'أحمد محمد',
vehicles: [{ id: 1 }, { id: 2 }],
maintenanceVisits: [
{ cost: 100, visitDate: new Date('2024-01-15') },
{ cost: 200, visitDate: new Date('2024-01-10') },
{ cost: 150, visitDate: new Date('2024-01-05') },
],
};
mockPrisma.customer.findUnique.mockResolvedValue(mockCustomer);
const result = await getCustomerStats(customerId);
expect(result).toEqual({
totalVehicles: 2,
totalVisits: 3,
totalSpent: 450,
lastVisitDate: new Date('2024-01-15'),
});
});
it('should return null if customer not found', async () => {
const customerId = 999;
mockPrisma.customer.findUnique.mockResolvedValue(null);
const result = await getCustomerStats(customerId);
expect(result).toBeNull();
});
});
});

View File

@@ -0,0 +1,293 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock the auth middleware
vi.mock('../auth-middleware.server', () => ({
requireAuth: vi.fn().mockResolvedValue({
id: 1,
name: 'Test User',
username: 'testuser',
email: 'test@example.com',
authLevel: 1,
status: 'active',
}),
}));
// Mock the customer management functions
vi.mock('../customer-management.server', () => ({
getCustomers: vi.fn(),
createCustomer: vi.fn(),
updateCustomer: vi.fn(),
deleteCustomer: vi.fn(),
getCustomerById: vi.fn(),
}));
// Mock validation
vi.mock('../validation', () => ({
validateCustomer: vi.fn(),
}));
import { loader, action } from '../../routes/customers';
import { getCustomers, createCustomer, updateCustomer, deleteCustomer } from '../customer-management.server';
import { validateCustomer } from '../validation';
const mockGetCustomers = getCustomers as any;
const mockCreateCustomer = createCustomer as any;
const mockUpdateCustomer = updateCustomer as any;
const mockDeleteCustomer = deleteCustomer as any;
const mockValidateCustomer = validateCustomer as any;
describe('Customer Routes Integration', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('loader', () => {
it('should load customers with default pagination', async () => {
const mockCustomers = [
{
id: 1,
name: 'أحمد محمد',
phone: '0501234567',
email: 'ahmed@example.com',
address: 'الرياض',
createdDate: new Date(),
updateDate: new Date(),
vehicles: [],
maintenanceVisits: [],
},
];
mockGetCustomers.mockResolvedValue({
customers: mockCustomers,
total: 1,
totalPages: 1,
});
const request = new Request('http://localhost:3000/customers');
const response = await loader({ request, params: {}, context: {} });
const data = await response.json();
expect(mockGetCustomers).toHaveBeenCalledWith('', 1, 10);
expect(data.customers).toEqual(mockCustomers);
expect(data.total).toBe(1);
expect(data.totalPages).toBe(1);
expect(data.currentPage).toBe(1);
});
it('should handle search and pagination parameters', async () => {
mockGetCustomers.mockResolvedValue({
customers: [],
total: 0,
totalPages: 0,
});
const request = new Request('http://localhost:3000/customers?search=أحمد&page=2&limit=20');
await loader({ request, params: {}, context: {} });
expect(mockGetCustomers).toHaveBeenCalledWith('أحمد', 2, 20);
});
});
describe('action', () => {
it('should create customer successfully', async () => {
const mockCustomer = {
id: 1,
name: 'أحمد محمد',
phone: '0501234567',
email: 'ahmed@example.com',
address: 'الرياض',
createdDate: new Date(),
updateDate: new Date(),
};
mockValidateCustomer.mockReturnValue({
isValid: true,
errors: {},
});
mockCreateCustomer.mockResolvedValue({
success: true,
customer: mockCustomer,
});
const formData = new FormData();
formData.append('_action', 'create');
formData.append('name', 'أحمد محمد');
formData.append('phone', '0501234567');
formData.append('email', 'ahmed@example.com');
formData.append('address', 'الرياض');
const request = new Request('http://localhost:3000/customers', {
method: 'POST',
body: formData,
});
const response = await action({ request, params: {}, context: {} });
const data = await response.json();
expect(mockValidateCustomer).toHaveBeenCalledWith({
name: 'أحمد محمد',
phone: '0501234567',
email: 'ahmed@example.com',
address: 'الرياض',
});
expect(mockCreateCustomer).toHaveBeenCalledWith({
name: 'أحمد محمد',
phone: '0501234567',
email: 'ahmed@example.com',
address: 'الرياض',
});
expect(data.success).toBe(true);
expect(data.customer).toEqual(mockCustomer);
expect(data.action).toBe('create');
expect(data.message).toBe('تم إنشاء العميل بنجاح');
});
it('should return validation errors for invalid data', async () => {
mockValidateCustomer.mockReturnValue({
isValid: false,
errors: {
name: 'اسم العميل مطلوب',
email: 'البريد الإلكتروني غير صحيح',
},
});
const formData = new FormData();
formData.append('_action', 'create');
formData.append('name', '');
formData.append('email', 'invalid-email');
const request = new Request('http://localhost:3000/customers', {
method: 'POST',
body: formData,
});
const response = await action({ request, params: {}, context: {} });
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.errors).toEqual({
name: 'اسم العميل مطلوب',
email: 'البريد الإلكتروني غير صحيح',
});
expect(mockCreateCustomer).not.toHaveBeenCalled();
});
it('should update customer successfully', async () => {
const mockCustomer = {
id: 1,
name: 'أحمد محمد المحدث',
phone: '0509876543',
email: 'ahmed.updated@example.com',
address: 'جدة',
createdDate: new Date(),
updateDate: new Date(),
};
mockValidateCustomer.mockReturnValue({
isValid: true,
errors: {},
});
mockUpdateCustomer.mockResolvedValue({
success: true,
customer: mockCustomer,
});
const formData = new FormData();
formData.append('_action', 'update');
formData.append('id', '1');
formData.append('name', 'أحمد محمد المحدث');
formData.append('phone', '0509876543');
formData.append('email', 'ahmed.updated@example.com');
formData.append('address', 'جدة');
const request = new Request('http://localhost:3000/customers', {
method: 'POST',
body: formData,
});
const response = await action({ request, params: {}, context: {} });
const data = await response.json();
expect(mockUpdateCustomer).toHaveBeenCalledWith(1, {
name: 'أحمد محمد المحدث',
phone: '0509876543',
email: 'ahmed.updated@example.com',
address: 'جدة',
});
expect(data.success).toBe(true);
expect(data.customer).toEqual(mockCustomer);
expect(data.action).toBe('update');
expect(data.message).toBe('تم تحديث العميل بنجاح');
});
it('should delete customer successfully', async () => {
mockDeleteCustomer.mockResolvedValue({
success: true,
});
const formData = new FormData();
formData.append('_action', 'delete');
formData.append('id', '1');
const request = new Request('http://localhost:3000/customers', {
method: 'POST',
body: formData,
});
const response = await action({ request, params: {}, context: {} });
const data = await response.json();
expect(mockDeleteCustomer).toHaveBeenCalledWith(1);
expect(data.success).toBe(true);
expect(data.action).toBe('delete');
expect(data.message).toBe('تم حذف العميل بنجاح');
});
it('should handle delete errors', async () => {
mockDeleteCustomer.mockResolvedValue({
success: false,
error: 'لا يمكن حذف العميل لأنه يملك مركبات',
});
const formData = new FormData();
formData.append('_action', 'delete');
formData.append('id', '1');
const request = new Request('http://localhost:3000/customers', {
method: 'POST',
body: formData,
});
const response = await action({ request, params: {}, context: {} });
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe('لا يمكن حذف العميل لأنه يملك مركبات');
});
it('should handle unknown action', async () => {
const formData = new FormData();
formData.append('_action', 'unknown');
const request = new Request('http://localhost:3000/customers', {
method: 'POST',
body: formData,
});
const response = await action({ request, params: {}, context: {} });
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
expect(data.error).toBe('إجراء غير صحيح');
expect(data.action).toBe('unknown');
});
});
});

View File

@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest';
import { validateCustomer } from '../validation';
describe('Customer Validation', () => {
describe('validateCustomer', () => {
it('should validate required name field', () => {
const result = validateCustomer({ name: '' });
expect(result.isValid).toBe(false);
expect(result.errors.name).toBe('اسم العميل مطلوب');
});
it('should validate name length', () => {
const longName = 'أ'.repeat(101);
const result = validateCustomer({ name: longName });
expect(result.isValid).toBe(false);
expect(result.errors.name).toBe('الاسم يجب أن يكون أقل من 100 حرف');
});
it('should validate phone format', () => {
const result = validateCustomer({
name: 'أحمد محمد',
phone: 'invalid-phone'
});
expect(result.isValid).toBe(false);
expect(result.errors.phone).toBe('رقم الهاتف غير صحيح');
});
it('should accept valid phone formats', () => {
const validPhones = [
'0501234567',
'+966501234567',
'050 123 4567',
'050-123-4567',
'(050) 123-4567',
];
validPhones.forEach(phone => {
const result = validateCustomer({
name: 'أحمد محمد',
phone
});
expect(result.isValid).toBe(true);
expect(result.errors.phone).toBeUndefined();
});
});
it('should validate email format', () => {
const result = validateCustomer({
name: 'أحمد محمد',
email: 'invalid-email'
});
expect(result.isValid).toBe(false);
expect(result.errors.email).toBe('البريد الإلكتروني غير صحيح');
});
it('should accept valid email format', () => {
const result = validateCustomer({
name: 'أحمد محمد',
email: 'ahmed@example.com'
});
expect(result.isValid).toBe(true);
expect(result.errors.email).toBeUndefined();
});
it('should allow empty optional fields', () => {
const result = validateCustomer({
name: 'أحمد محمد',
phone: '',
email: '',
address: ''
});
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
it('should validate complete customer data', () => {
const result = validateCustomer({
name: 'أحمد محمد',
phone: '0501234567',
email: 'ahmed@example.com',
address: 'الرياض، المملكة العربية السعودية'
});
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
it('should handle undefined fields gracefully', () => {
const result = validateCustomer({
name: 'أحمد محمد',
phone: undefined,
email: undefined,
address: undefined
});
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
it('should trim whitespace from name', () => {
const result = validateCustomer({ name: ' أحمد محمد ' });
expect(result.isValid).toBe(true);
});
it('should reject name with only whitespace', () => {
const result = validateCustomer({ name: ' ' });
expect(result.isValid).toBe(false);
expect(result.errors.name).toBe('اسم العميل مطلوب');
});
});
});

View File

@@ -0,0 +1,278 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
createExpense,
getExpenses,
getExpenseById,
updateExpense,
deleteExpense,
getExpenseCategories,
getExpensesByCategory,
getTotalExpenses
} from '../expense-management.server';
import { prisma } from '../db.server';
describe('Expense Management', () => {
beforeEach(async () => {
// Clean up test data
await prisma.expense.deleteMany();
});
afterEach(async () => {
// Clean up test data
await prisma.expense.deleteMany();
});
describe('createExpense', () => {
it('should create a new expense', async () => {
const expenseData = {
description: 'قطع غيار للمحرك',
category: 'قطع غيار',
amount: 500.00,
expenseDate: new Date('2024-01-15'),
};
const expense = await createExpense(expenseData);
expect(expense).toBeDefined();
expect(expense.description).toBe(expenseData.description);
expect(expense.category).toBe(expenseData.category);
expect(expense.amount).toBe(expenseData.amount);
expect(expense.expenseDate).toEqual(expenseData.expenseDate);
});
it('should create expense with current date if no date provided', async () => {
const expenseData = {
description: 'مصروف عام',
category: 'أخرى',
amount: 100.00,
};
const expense = await createExpense(expenseData);
expect(expense).toBeDefined();
expect(expense.expenseDate).toBeInstanceOf(Date);
});
});
describe('getExpenses', () => {
beforeEach(async () => {
// Create test expenses
await prisma.expense.createMany({
data: [
{
description: 'قطع غيار',
category: 'قطع غيار',
amount: 500.00,
expenseDate: new Date('2024-01-15'),
},
{
description: 'أدوات صيانة',
category: 'أدوات',
amount: 200.00,
expenseDate: new Date('2024-01-10'),
},
{
description: 'إيجار المحل',
category: 'إيجار',
amount: 3000.00,
expenseDate: new Date('2024-01-01'),
},
],
});
});
it('should get all expenses with pagination', async () => {
const result = await getExpenses();
expect(result.expenses).toHaveLength(3);
expect(result.total).toBe(3);
expect(result.totalPages).toBe(1);
});
it('should filter expenses by search query', async () => {
const result = await getExpenses('قطع غيار');
expect(result.expenses).toHaveLength(1);
expect(result.expenses[0].description).toBe('قطع غيار');
});
it('should filter expenses by category', async () => {
const result = await getExpenses(undefined, 1, 10, 'أدوات');
expect(result.expenses).toHaveLength(1);
expect(result.expenses[0].category).toBe('أدوات');
});
it('should filter expenses by date range', async () => {
const dateFrom = new Date('2024-01-10');
const dateTo = new Date('2024-01-20');
const result = await getExpenses(undefined, 1, 10, undefined, dateFrom, dateTo);
expect(result.expenses).toHaveLength(2);
});
it('should handle pagination correctly', async () => {
const result = await getExpenses(undefined, 1, 2);
expect(result.expenses).toHaveLength(2);
expect(result.totalPages).toBe(2);
});
});
describe('getExpenseById', () => {
it('should get expense by ID', async () => {
const createdExpense = await prisma.expense.create({
data: {
description: 'تست مصروف',
category: 'أخرى',
amount: 100.00,
},
});
const expense = await getExpenseById(createdExpense.id);
expect(expense).toBeDefined();
expect(expense?.id).toBe(createdExpense.id);
expect(expense?.description).toBe('تست مصروف');
});
it('should return null for non-existent expense', async () => {
const expense = await getExpenseById(999);
expect(expense).toBeNull();
});
});
describe('updateExpense', () => {
it('should update expense successfully', async () => {
const createdExpense = await prisma.expense.create({
data: {
description: 'مصروف قديم',
category: 'أخرى',
amount: 100.00,
},
});
const updateData = {
description: 'مصروف محدث',
category: 'قطع غيار',
amount: 200.00,
};
const updatedExpense = await updateExpense(createdExpense.id, updateData);
expect(updatedExpense.description).toBe(updateData.description);
expect(updatedExpense.category).toBe(updateData.category);
expect(updatedExpense.amount).toBe(updateData.amount);
});
});
describe('deleteExpense', () => {
it('should delete expense successfully', async () => {
const createdExpense = await prisma.expense.create({
data: {
description: 'مصروف للحذف',
category: 'أخرى',
amount: 100.00,
},
});
await deleteExpense(createdExpense.id);
const deletedExpense = await prisma.expense.findUnique({
where: { id: createdExpense.id },
});
expect(deletedExpense).toBeNull();
});
});
describe('getExpenseCategories', () => {
beforeEach(async () => {
await prisma.expense.createMany({
data: [
{ description: 'مصروف 1', category: 'قطع غيار', amount: 100 },
{ description: 'مصروف 2', category: 'أدوات', amount: 200 },
{ description: 'مصروف 3', category: 'قطع غيار', amount: 150 },
],
});
});
it('should get unique expense categories', async () => {
const categories = await getExpenseCategories();
expect(categories).toHaveLength(2);
expect(categories).toContain('قطع غيار');
expect(categories).toContain('أدوات');
});
});
describe('getExpensesByCategory', () => {
beforeEach(async () => {
await prisma.expense.createMany({
data: [
{ description: 'مصروف 1', category: 'قطع غيار', amount: 100 },
{ description: 'مصروف 2', category: 'أدوات', amount: 200 },
{ description: 'مصروف 3', category: 'قطع غيار', amount: 150 },
],
});
});
it('should group expenses by category', async () => {
const result = await getExpensesByCategory();
expect(result).toHaveLength(2);
const spareParts = result.find(r => r.category === 'قطع غيار');
expect(spareParts?.total).toBe(250);
expect(spareParts?.count).toBe(2);
const tools = result.find(r => r.category === 'أدوات');
expect(tools?.total).toBe(200);
expect(tools?.count).toBe(1);
});
});
describe('getTotalExpenses', () => {
beforeEach(async () => {
await prisma.expense.createMany({
data: [
{
description: 'مصروف 1',
category: 'قطع غيار',
amount: 100,
expenseDate: new Date('2024-01-15'),
},
{
description: 'مصروف 2',
category: 'أدوات',
amount: 200,
expenseDate: new Date('2024-01-10'),
},
{
description: 'مصروف 3',
category: 'إيجار',
amount: 3000,
expenseDate: new Date('2023-12-15'),
},
],
});
});
it('should calculate total expenses', async () => {
const total = await getTotalExpenses();
expect(total).toBe(3300);
});
it('should calculate total expenses for date range', async () => {
const dateFrom = new Date('2024-01-01');
const dateTo = new Date('2024-01-31');
const total = await getTotalExpenses(dateFrom, dateTo);
expect(total).toBe(300); // Only expenses from January 2024
});
});
});

View File

@@ -0,0 +1,458 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
getFinancialSummary,
getMonthlyFinancialData,
getIncomeByMaintenanceType,
getExpenseBreakdown,
getTopCustomersByRevenue,
getFinancialTrends
} from '../financial-reporting.server';
import { prisma } from '../db.server';
describe('Financial Reporting', () => {
beforeEach(async () => {
// Clean up test data
await prisma.income.deleteMany();
await prisma.expense.deleteMany();
await prisma.maintenanceVisit.deleteMany();
await prisma.vehicle.deleteMany();
await prisma.customer.deleteMany();
});
afterEach(async () => {
// Clean up test data
await prisma.income.deleteMany();
await prisma.expense.deleteMany();
await prisma.maintenanceVisit.deleteMany();
await prisma.vehicle.deleteMany();
await prisma.customer.deleteMany();
});
describe('getFinancialSummary', () => {
beforeEach(async () => {
// Create test customer
const customer = await prisma.customer.create({
data: {
name: 'عميل تجريبي',
phone: '0501234567',
},
});
// Create test vehicle
const vehicle = await prisma.vehicle.create({
data: {
plateNumber: 'ABC-123',
bodyType: 'سيدان',
manufacturer: 'تويوتا',
model: 'كامري',
year: 2020,
transmission: 'Automatic',
fuel: 'Gasoline',
useType: 'personal',
ownerId: customer.id,
},
});
// Create test maintenance visit
const visit = await prisma.maintenanceVisit.create({
data: {
vehicleId: vehicle.id,
customerId: customer.id,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 200.00,
kilometers: 50000,
nextVisitDelay: 3,
},
});
// Create test income
await prisma.income.create({
data: {
maintenanceVisitId: visit.id,
amount: 200.00,
incomeDate: new Date('2024-01-15'),
},
});
// Create test expenses
await prisma.expense.createMany({
data: [
{
description: 'قطع غيار',
category: 'قطع غيار',
amount: 50.00,
expenseDate: new Date('2024-01-10'),
},
{
description: 'أدوات',
category: 'أدوات',
amount: 30.00,
expenseDate: new Date('2024-01-12'),
},
],
});
});
it('should calculate financial summary correctly', async () => {
const summary = await getFinancialSummary();
expect(summary.totalIncome).toBe(200.00);
expect(summary.totalExpenses).toBe(80.00);
expect(summary.netProfit).toBe(120.00);
expect(summary.incomeCount).toBe(1);
expect(summary.expenseCount).toBe(2);
expect(summary.profitMargin).toBe(60.0); // (120/200) * 100
});
it('should filter by date range', async () => {
const dateFrom = new Date('2024-01-11');
const dateTo = new Date('2024-01-20');
const summary = await getFinancialSummary(dateFrom, dateTo);
expect(summary.totalIncome).toBe(200.00);
expect(summary.totalExpenses).toBe(30.00); // Only one expense in range
expect(summary.netProfit).toBe(170.00);
});
});
describe('getIncomeByMaintenanceType', () => {
beforeEach(async () => {
// Create test data
const customer = await prisma.customer.create({
data: { name: 'عميل تجريبي', phone: '0501234567' },
});
const vehicle = await prisma.vehicle.create({
data: {
plateNumber: 'ABC-123',
bodyType: 'سيدان',
manufacturer: 'تويوتا',
model: 'كامري',
year: 2020,
transmission: 'Automatic',
fuel: 'Gasoline',
useType: 'personal',
ownerId: customer.id,
},
});
// Create maintenance visits with different types
const visit1 = await prisma.maintenanceVisit.create({
data: {
vehicleId: vehicle.id,
customerId: customer.id,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 200.00,
kilometers: 50000,
nextVisitDelay: 3,
},
});
const visit2 = await prisma.maintenanceVisit.create({
data: {
vehicleId: vehicle.id,
customerId: customer.id,
maintenanceType: 'فحص دوري',
description: 'فحص دوري شامل',
cost: 150.00,
kilometers: 51000,
nextVisitDelay: 3,
},
});
const visit3 = await prisma.maintenanceVisit.create({
data: {
vehicleId: vehicle.id,
customerId: customer.id,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت مرة أخرى',
cost: 180.00,
kilometers: 52000,
nextVisitDelay: 3,
},
});
// Create corresponding income records
await prisma.income.createMany({
data: [
{ maintenanceVisitId: visit1.id, amount: 200.00 },
{ maintenanceVisitId: visit2.id, amount: 150.00 },
{ maintenanceVisitId: visit3.id, amount: 180.00 },
],
});
});
it('should group income by maintenance type', async () => {
const result = await getIncomeByMaintenanceType();
expect(result).toHaveLength(2);
const oilChange = result.find(r => r.category === 'تغيير زيت');
expect(oilChange?.amount).toBe(380.00);
expect(oilChange?.count).toBe(2);
expect(oilChange?.percentage).toBeCloseTo(71.7, 1); // 380/530 * 100
const inspection = result.find(r => r.category === 'فحص دوري');
expect(inspection?.amount).toBe(150.00);
expect(inspection?.count).toBe(1);
expect(inspection?.percentage).toBeCloseTo(28.3, 1); // 150/530 * 100
});
});
describe('getExpenseBreakdown', () => {
beforeEach(async () => {
await prisma.expense.createMany({
data: [
{ description: 'قطع غيار 1', category: 'قطع غيار', amount: 100 },
{ description: 'قطع غيار 2', category: 'قطع غيار', amount: 150 },
{ description: 'أدوات 1', category: 'أدوات', amount: 200 },
{ description: 'إيجار', category: 'إيجار', amount: 3000 },
],
});
});
it('should group expenses by category with percentages', async () => {
const result = await getExpenseBreakdown();
expect(result).toHaveLength(3);
const rent = result.find(r => r.category === 'إيجار');
expect(rent?.amount).toBe(3000);
expect(rent?.count).toBe(1);
expect(rent?.percentage).toBeCloseTo(86.96, 1); // 3000/3450 * 100
const spareParts = result.find(r => r.category === 'قطع غيار');
expect(spareParts?.amount).toBe(250);
expect(spareParts?.count).toBe(2);
expect(spareParts?.percentage).toBeCloseTo(7.25, 1); // 250/3450 * 100
const tools = result.find(r => r.category === 'أدوات');
expect(tools?.amount).toBe(200);
expect(tools?.count).toBe(1);
expect(tools?.percentage).toBeCloseTo(5.80, 1); // 200/3450 * 100
});
});
describe('getTopCustomersByRevenue', () => {
beforeEach(async () => {
// Create test customers
const customer1 = await prisma.customer.create({
data: { name: 'عميل أول', phone: '0501111111' },
});
const customer2 = await prisma.customer.create({
data: { name: 'عميل ثاني', phone: '0502222222' },
});
// Create vehicles
const vehicle1 = await prisma.vehicle.create({
data: {
plateNumber: 'ABC-111',
bodyType: 'سيدان',
manufacturer: 'تويوتا',
model: 'كامري',
year: 2020,
transmission: 'Automatic',
fuel: 'Gasoline',
useType: 'personal',
ownerId: customer1.id,
},
});
const vehicle2 = await prisma.vehicle.create({
data: {
plateNumber: 'ABC-222',
bodyType: 'SUV',
manufacturer: 'هيونداي',
model: 'توسان',
year: 2021,
transmission: 'Automatic',
fuel: 'Gasoline',
useType: 'personal',
ownerId: customer2.id,
},
});
// Create maintenance visits
const visit1 = await prisma.maintenanceVisit.create({
data: {
vehicleId: vehicle1.id,
customerId: customer1.id,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت',
cost: 300.00,
kilometers: 50000,
nextVisitDelay: 3,
},
});
const visit2 = await prisma.maintenanceVisit.create({
data: {
vehicleId: vehicle1.id,
customerId: customer1.id,
maintenanceType: 'فحص دوري',
description: 'فحص دوري',
cost: 200.00,
kilometers: 51000,
nextVisitDelay: 3,
},
});
const visit3 = await prisma.maintenanceVisit.create({
data: {
vehicleId: vehicle2.id,
customerId: customer2.id,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت',
cost: 150.00,
kilometers: 30000,
nextVisitDelay: 3,
},
});
// Create income records
await prisma.income.createMany({
data: [
{ maintenanceVisitId: visit1.id, amount: 300.00 },
{ maintenanceVisitId: visit2.id, amount: 200.00 },
{ maintenanceVisitId: visit3.id, amount: 150.00 },
],
});
});
it('should return top customers by revenue', async () => {
const result = await getTopCustomersByRevenue(10);
expect(result).toHaveLength(2);
// Should be sorted by revenue descending
expect(result[0].customerName).toBe('عميل أول');
expect(result[0].totalRevenue).toBe(500.00);
expect(result[0].visitCount).toBe(2);
expect(result[1].customerName).toBe('عميل ثاني');
expect(result[1].totalRevenue).toBe(150.00);
expect(result[1].visitCount).toBe(1);
});
it('should limit results correctly', async () => {
const result = await getTopCustomersByRevenue(1);
expect(result).toHaveLength(1);
expect(result[0].customerName).toBe('عميل أول');
});
});
describe('getFinancialTrends', () => {
beforeEach(async () => {
// Create test customer and vehicle
const customer = await prisma.customer.create({
data: { name: 'عميل تجريبي', phone: '0501234567' },
});
const vehicle = await prisma.vehicle.create({
data: {
plateNumber: 'ABC-123',
bodyType: 'سيدان',
manufacturer: 'تويوتا',
model: 'كامري',
year: 2020,
transmission: 'Automatic',
fuel: 'Gasoline',
useType: 'personal',
ownerId: customer.id,
},
});
// Create maintenance visits for different periods
const currentVisit = await prisma.maintenanceVisit.create({
data: {
vehicleId: vehicle.id,
customerId: customer.id,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت حالي',
cost: 300.00,
kilometers: 50000,
nextVisitDelay: 3,
},
});
const previousVisit = await prisma.maintenanceVisit.create({
data: {
vehicleId: vehicle.id,
customerId: customer.id,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت سابق',
cost: 200.00,
kilometers: 45000,
nextVisitDelay: 3,
},
});
// Create income for current period (Jan 2024)
await prisma.income.create({
data: {
maintenanceVisitId: currentVisit.id,
amount: 300.00,
incomeDate: new Date('2024-01-15'),
},
});
// Create income for previous period (Dec 2023)
await prisma.income.create({
data: {
maintenanceVisitId: previousVisit.id,
amount: 200.00,
incomeDate: new Date('2023-12-15'),
},
});
// Create expenses for current period
await prisma.expense.create({
data: {
description: 'مصروف حالي',
category: 'قطع غيار',
amount: 100.00,
expenseDate: new Date('2024-01-10'),
},
});
// Create expenses for previous period
await prisma.expense.create({
data: {
description: 'مصروف سابق',
category: 'قطع غيار',
amount: 80.00,
expenseDate: new Date('2023-12-10'),
},
});
});
it('should calculate financial trends correctly', async () => {
const dateFrom = new Date('2024-01-01');
const dateTo = new Date('2024-01-31');
const result = await getFinancialTrends(dateFrom, dateTo);
expect(result.currentPeriod.totalIncome).toBe(300.00);
expect(result.currentPeriod.totalExpenses).toBe(100.00);
expect(result.currentPeriod.netProfit).toBe(200.00);
expect(result.previousPeriod.totalIncome).toBe(200.00);
expect(result.previousPeriod.totalExpenses).toBe(80.00);
expect(result.previousPeriod.netProfit).toBe(120.00);
// Income growth: (300-200)/200 * 100 = 50%
expect(result.trends.incomeGrowth).toBeCloseTo(50.0, 1);
// Expense growth: (100-80)/80 * 100 = 25%
expect(result.trends.expenseGrowth).toBeCloseTo(25.0, 1);
// Profit growth: (200-120)/120 * 100 = 66.67%
expect(result.trends.profitGrowth).toBeCloseTo(66.67, 1);
});
});
});

View File

@@ -0,0 +1,201 @@
import { describe, it, expect } from 'vitest';
import {
validateUserData,
validateCustomerData,
validateVehicleData,
validateMaintenanceVisitData,
validateExpenseData
} from '../form-validation';
describe('Form Validation', () => {
describe('validateUserData', () => {
it('should validate valid user data', () => {
const validData = {
name: 'أحمد محمد',
username: 'ahmed123',
email: 'ahmed@example.com',
password: 'password123',
authLevel: 3,
status: 'active',
};
const result = validateUserData(validData);
expect(result.success).toBe(true);
expect(result.errors).toEqual({});
});
it('should return errors for invalid user data', () => {
const invalidData = {
name: '',
username: 'ab',
email: 'invalid-email',
password: '123',
authLevel: 5,
status: 'invalid',
};
const result = validateUserData(invalidData);
expect(result.success).toBe(false);
expect(result.errors.name).toBe('الاسم مطلوب');
expect(result.errors.username).toBe('اسم المستخدم يجب أن يكون على الأقل 3 أحرف');
expect(result.errors.email).toBe('البريد الإلكتروني غير صحيح');
expect(result.errors.password).toBe('كلمة المرور يجب أن تكون على الأقل 6 أحرف');
});
});
describe('validateCustomerData', () => {
it('should validate valid customer data', () => {
const validData = {
name: 'محمد أحمد',
phone: '+966501234567',
email: 'mohammed@example.com',
address: 'الرياض، المملكة العربية السعودية',
};
const result = validateCustomerData(validData);
expect(result.success).toBe(true);
expect(result.errors).toEqual({});
});
it('should validate customer with minimal data', () => {
const validData = {
name: 'سارة علي',
phone: '',
email: '',
address: '',
};
const result = validateCustomerData(validData);
expect(result.success).toBe(true);
expect(result.errors).toEqual({});
});
it('should return errors for invalid customer data', () => {
const invalidData = {
name: '',
phone: 'invalid-phone',
email: 'invalid-email',
address: 'Valid address',
};
const result = validateCustomerData(invalidData);
expect(result.success).toBe(false);
expect(result.errors.name).toBe('اسم العميل مطلوب');
expect(result.errors.phone).toBe('رقم الهاتف غير صحيح');
expect(result.errors.email).toBe('البريد الإلكتروني غير صحيح');
});
});
describe('validateVehicleData', () => {
it('should validate valid vehicle data', () => {
const validData = {
plateNumber: 'ABC-1234',
bodyType: 'Sedan',
manufacturer: 'Toyota',
model: 'Camry',
trim: 'LE',
year: '2023',
transmission: 'Automatic',
fuel: 'Gasoline',
cylinders: '4',
engineDisplacement: '2.5',
useType: 'personal',
ownerId: '1',
};
const result = validateVehicleData(validData);
expect(result.success).toBe(true);
expect(result.errors).toEqual({});
});
it('should return errors for invalid vehicle data', () => {
const invalidData = {
plateNumber: '',
bodyType: '',
manufacturer: '',
model: '',
year: '1800',
transmission: 'invalid',
fuel: 'invalid',
cylinders: '20',
engineDisplacement: '50',
useType: 'invalid',
ownerId: '0',
};
const result = validateVehicleData(invalidData);
expect(result.success).toBe(false);
expect(result.errors.plateNumber).toBe('رقم اللوحة مطلوب');
expect(result.errors.bodyType).toBe('نوع الهيكل مطلوب');
expect(result.errors.manufacturer).toBe('الشركة المصنعة مطلوبة');
expect(result.errors.model).toBe('الموديل مطلوب');
expect(result.errors.ownerId).toBe('مالك المركبة مطلوب');
});
});
describe('validateMaintenanceVisitData', () => {
it('should validate valid maintenance visit data', () => {
const validData = {
vehicleId: '1',
customerId: '1',
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك والفلتر',
cost: '150.50',
paymentStatus: 'paid',
kilometers: '50000',
nextVisitDelay: '3',
};
const result = validateMaintenanceVisitData(validData);
expect(result.success).toBe(true);
expect(result.errors).toEqual({});
});
it('should return errors for invalid maintenance visit data', () => {
const invalidData = {
vehicleId: '0',
customerId: '0',
maintenanceType: '',
description: '',
cost: '-10',
paymentStatus: 'invalid',
kilometers: '-100',
nextVisitDelay: '5',
};
const result = validateMaintenanceVisitData(invalidData);
expect(result.success).toBe(false);
expect(result.errors.vehicleId).toBe('المركبة مطلوبة');
expect(result.errors.customerId).toBe('العميل مطلوب');
expect(result.errors.maintenanceType).toBe('نوع الصيانة مطلوب');
expect(result.errors.description).toBe('وصف الصيانة مطلوب');
});
});
describe('validateExpenseData', () => {
it('should validate valid expense data', () => {
const validData = {
description: 'شراء قطع غيار',
category: 'قطع غيار',
amount: '250.75',
};
const result = validateExpenseData(validData);
expect(result.success).toBe(true);
expect(result.errors).toEqual({});
});
it('should return errors for invalid expense data', () => {
const invalidData = {
description: '',
category: '',
amount: '0',
};
const result = validateExpenseData(invalidData);
expect(result.success).toBe(false);
expect(result.errors.description).toBe('وصف المصروف مطلوب');
expect(result.errors.category).toBe('فئة المصروف مطلوبة');
});
});
});

View File

@@ -0,0 +1,434 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
createMaintenanceVisit,
getMaintenanceVisits,
getMaintenanceVisitById,
updateMaintenanceVisit,
deleteMaintenanceVisit,
getVehicleMaintenanceHistory,
getCustomerMaintenanceHistory
} from '../maintenance-visit-management.server';
import { prisma } from '../db.server';
import type { Customer, Vehicle } from '@prisma/client';
describe('Maintenance Visit Management', () => {
let testCustomer: Customer;
let testVehicle: Vehicle;
beforeEach(async () => {
// Create test customer
testCustomer = await prisma.customer.create({
data: {
name: 'أحمد محمد',
phone: '0501234567',
email: 'ahmed@example.com',
},
});
// Create test vehicle
testVehicle = await prisma.vehicle.create({
data: {
plateNumber: 'أ ب ج 123',
bodyType: 'سيدان',
manufacturer: 'تويوتا',
model: 'كامري',
year: 2020,
transmission: 'Automatic',
fuel: 'Gasoline',
useType: 'personal',
ownerId: testCustomer.id,
},
});
});
afterEach(async () => {
// Clean up test data
await prisma.income.deleteMany();
await prisma.maintenanceVisit.deleteMany();
await prisma.vehicle.deleteMany();
await prisma.customer.deleteMany();
});
describe('createMaintenanceVisit', () => {
it('should create a maintenance visit successfully', async () => {
const visitData = {
vehicleId: testVehicle.id,
customerId: testCustomer.id,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك والفلتر',
cost: 150.00,
paymentStatus: 'paid',
kilometers: 50000,
nextVisitDelay: 3,
};
const visit = await createMaintenanceVisit(visitData);
expect(visit).toBeDefined();
expect(visit.vehicleId).toBe(testVehicle.id);
expect(visit.customerId).toBe(testCustomer.id);
expect(visit.maintenanceType).toBe('تغيير زيت');
expect(visit.cost).toBe(150.00);
expect(visit.nextVisitDelay).toBe(3);
// Check that vehicle was updated
const updatedVehicle = await prisma.vehicle.findUnique({
where: { id: testVehicle.id },
});
expect(updatedVehicle?.lastVisitDate).toBeDefined();
expect(updatedVehicle?.suggestedNextVisitDate).toBeDefined();
// Check that income was created
const income = await prisma.income.findFirst({
where: { maintenanceVisitId: visit.id },
});
expect(income).toBeDefined();
expect(income?.amount).toBe(150.00);
});
it('should calculate next visit date correctly', async () => {
const visitData = {
vehicleId: testVehicle.id,
customerId: testCustomer.id,
maintenanceType: 'فحص دوري',
description: 'فحص دوري شامل',
cost: 200.00,
kilometers: 45000,
nextVisitDelay: 2, // 2 months
};
await createMaintenanceVisit(visitData);
const updatedVehicle = await prisma.vehicle.findUnique({
where: { id: testVehicle.id },
});
expect(updatedVehicle?.suggestedNextVisitDate).toBeDefined();
// Check that the suggested date is approximately 2 months from now
const now = new Date();
const expectedDate = new Date();
expectedDate.setMonth(expectedDate.getMonth() + 2);
const actualDate = updatedVehicle!.suggestedNextVisitDate!;
const timeDiff = Math.abs(actualDate.getTime() - expectedDate.getTime());
const daysDiff = timeDiff / (1000 * 60 * 60 * 24);
expect(daysDiff).toBeLessThan(2); // Allow 2 days difference
});
});
describe('getMaintenanceVisits', () => {
beforeEach(async () => {
// Create test visits
await createMaintenanceVisit({
vehicleId: testVehicle.id,
customerId: testCustomer.id,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 150.00,
kilometers: 50000,
nextVisitDelay: 3,
});
await createMaintenanceVisit({
vehicleId: testVehicle.id,
customerId: testCustomer.id,
maintenanceType: 'فحص فرامل',
description: 'فحص وتنظيف الفرامل',
cost: 100.00,
kilometers: 52000,
nextVisitDelay: 2,
});
});
it('should get all maintenance visits', async () => {
const result = await getMaintenanceVisits();
expect(result.visits).toHaveLength(2);
expect(result.total).toBe(2);
expect(result.totalPages).toBe(1);
});
it('should search maintenance visits by maintenance type', async () => {
const result = await getMaintenanceVisits('تغيير زيت');
expect(result.visits).toHaveLength(1);
expect(result.visits[0].maintenanceType).toBe('تغيير زيت');
});
it('should filter by vehicle ID', async () => {
const result = await getMaintenanceVisits('', 1, 10, testVehicle.id);
expect(result.visits).toHaveLength(2);
result.visits.forEach(visit => {
expect(visit.vehicleId).toBe(testVehicle.id);
});
});
it('should filter by customer ID', async () => {
const result = await getMaintenanceVisits('', 1, 10, undefined, testCustomer.id);
expect(result.visits).toHaveLength(2);
result.visits.forEach(visit => {
expect(visit.customerId).toBe(testCustomer.id);
});
});
it('should paginate results', async () => {
const result = await getMaintenanceVisits('', 1, 1);
expect(result.visits).toHaveLength(1);
expect(result.total).toBe(2);
expect(result.totalPages).toBe(2);
});
});
describe('getMaintenanceVisitById', () => {
it('should get a maintenance visit by ID', async () => {
const createdVisit = await createMaintenanceVisit({
vehicleId: testVehicle.id,
customerId: testCustomer.id,
maintenanceType: 'تغيير إطارات',
description: 'تغيير الإطارات الأمامية',
cost: 800.00,
kilometers: 55000,
nextVisitDelay: 4,
});
const visit = await getMaintenanceVisitById(createdVisit.id);
expect(visit).toBeDefined();
expect(visit?.id).toBe(createdVisit.id);
expect(visit?.vehicle).toBeDefined();
expect(visit?.customer).toBeDefined();
expect(visit?.income).toBeDefined();
});
it('should return null for non-existent visit', async () => {
const visit = await getMaintenanceVisitById(99999);
expect(visit).toBeNull();
});
});
describe('updateMaintenanceVisit', () => {
it('should update a maintenance visit successfully', async () => {
const createdVisit = await createMaintenanceVisit({
vehicleId: testVehicle.id,
customerId: testCustomer.id,
maintenanceType: 'فحص دوري',
description: 'فحص دوري أولي',
cost: 200.00,
kilometers: 48000,
nextVisitDelay: 3,
});
const updateData = {
maintenanceType: 'فحص شامل',
description: 'فحص شامل محدث',
cost: 250.00,
paymentStatus: 'paid',
kilometers: 48500,
nextVisitDelay: 2,
};
const updatedVisit = await updateMaintenanceVisit(createdVisit.id, updateData);
expect(updatedVisit.maintenanceType).toBe('فحص شامل');
expect(updatedVisit.description).toBe('فحص شامل محدث');
expect(updatedVisit.cost).toBe(250.00);
expect(updatedVisit.paymentStatus).toBe('paid');
expect(updatedVisit.kilometers).toBe(48500);
expect(updatedVisit.nextVisitDelay).toBe(2);
// Check that income was updated
const income = await prisma.income.findFirst({
where: { maintenanceVisitId: createdVisit.id },
});
expect(income?.amount).toBe(250.00);
});
it('should update vehicle dates when visit date changes', async () => {
const createdVisit = await createMaintenanceVisit({
vehicleId: testVehicle.id,
customerId: testCustomer.id,
maintenanceType: 'صيانة عامة',
description: 'صيانة عامة',
cost: 300.00,
kilometers: 50000,
nextVisitDelay: 3,
});
const newVisitDate = new Date();
newVisitDate.setDate(newVisitDate.getDate() - 5); // 5 days ago
await updateMaintenanceVisit(createdVisit.id, {
visitDate: newVisitDate,
nextVisitDelay: 4,
});
const updatedVehicle = await prisma.vehicle.findUnique({
where: { id: testVehicle.id },
});
expect(updatedVehicle?.lastVisitDate?.getTime()).toBe(newVisitDate.getTime());
// Check that suggested next visit date was recalculated
const expectedNextDate = new Date(newVisitDate);
expectedNextDate.setMonth(expectedNextDate.getMonth() + 4);
const actualNextDate = updatedVehicle!.suggestedNextVisitDate!;
const timeDiff = Math.abs(actualNextDate.getTime() - expectedNextDate.getTime());
const daysDiff = timeDiff / (1000 * 60 * 60 * 24);
expect(daysDiff).toBeLessThan(2);
});
});
describe('deleteMaintenanceVisit', () => {
it('should delete a maintenance visit successfully', async () => {
const createdVisit = await createMaintenanceVisit({
vehicleId: testVehicle.id,
customerId: testCustomer.id,
maintenanceType: 'تنظيف مكيف',
description: 'تنظيف وصيانة المكيف',
cost: 120.00,
kilometers: 47000,
nextVisitDelay: 2,
});
await deleteMaintenanceVisit(createdVisit.id);
// Check that visit was deleted
const deletedVisit = await prisma.maintenanceVisit.findUnique({
where: { id: createdVisit.id },
});
expect(deletedVisit).toBeNull();
// Check that income was deleted (cascade)
const income = await prisma.income.findFirst({
where: { maintenanceVisitId: createdVisit.id },
});
expect(income).toBeNull();
// Check that vehicle dates were cleared
const updatedVehicle = await prisma.vehicle.findUnique({
where: { id: testVehicle.id },
});
expect(updatedVehicle?.lastVisitDate).toBeNull();
expect(updatedVehicle?.suggestedNextVisitDate).toBeNull();
});
it('should update vehicle dates to most recent remaining visit after deletion', async () => {
// Create two visits
const firstVisit = await createMaintenanceVisit({
vehicleId: testVehicle.id,
customerId: testCustomer.id,
maintenanceType: 'زيت قديم',
description: 'تغيير زيت قديم',
cost: 100.00,
kilometers: 45000,
nextVisitDelay: 3,
visitDate: new Date('2023-01-01'),
});
const secondVisit = await createMaintenanceVisit({
vehicleId: testVehicle.id,
customerId: testCustomer.id,
maintenanceType: 'زيت جديد',
description: 'تغيير زيت جديد',
cost: 150.00,
kilometers: 50000,
nextVisitDelay: 2,
visitDate: new Date('2023-02-01'),
});
// Delete the more recent visit
await deleteMaintenanceVisit(secondVisit.id);
// Check that vehicle dates were updated to the remaining visit
const updatedVehicle = await prisma.vehicle.findUnique({
where: { id: testVehicle.id },
});
expect(updatedVehicle?.lastVisitDate?.getTime()).toBe(new Date('2023-01-01').getTime());
// Check that suggested next visit date was recalculated based on first visit
const expectedNextDate = new Date('2023-01-01');
expectedNextDate.setMonth(expectedNextDate.getMonth() + 3);
const actualNextDate = updatedVehicle!.suggestedNextVisitDate!;
expect(actualNextDate.getTime()).toBe(expectedNextDate.getTime());
});
});
describe('getVehicleMaintenanceHistory', () => {
it('should get maintenance history for a vehicle', async () => {
await createMaintenanceVisit({
vehicleId: testVehicle.id,
customerId: testCustomer.id,
maintenanceType: 'زيت أول',
description: 'تغيير زيت أول',
cost: 100.00,
kilometers: 45000,
nextVisitDelay: 3,
});
await createMaintenanceVisit({
vehicleId: testVehicle.id,
customerId: testCustomer.id,
maintenanceType: 'زيت ثاني',
description: 'تغيير زيت ثاني',
cost: 120.00,
kilometers: 48000,
nextVisitDelay: 3,
});
const history = await getVehicleMaintenanceHistory(testVehicle.id);
expect(history).toHaveLength(2);
expect(history[0].vehicleId).toBe(testVehicle.id);
expect(history[1].vehicleId).toBe(testVehicle.id);
// Should be ordered by visit date descending
expect(new Date(history[0].visitDate).getTime()).toBeGreaterThanOrEqual(
new Date(history[1].visitDate).getTime()
);
});
});
describe('getCustomerMaintenanceHistory', () => {
it('should get maintenance history for a customer', async () => {
await createMaintenanceVisit({
vehicleId: testVehicle.id,
customerId: testCustomer.id,
maintenanceType: 'صيانة أولى',
description: 'صيانة أولى',
cost: 200.00,
kilometers: 45000,
nextVisitDelay: 3,
});
await createMaintenanceVisit({
vehicleId: testVehicle.id,
customerId: testCustomer.id,
maintenanceType: 'صيانة ثانية',
description: 'صيانة ثانية',
cost: 250.00,
kilometers: 48000,
nextVisitDelay: 2,
});
const history = await getCustomerMaintenanceHistory(testCustomer.id);
expect(history).toHaveLength(2);
expect(history[0].customerId).toBe(testCustomer.id);
expect(history[1].customerId).toBe(testCustomer.id);
// Should be ordered by visit date descending
expect(new Date(history[0].visitDate).getTime()).toBeGreaterThanOrEqual(
new Date(history[1].visitDate).getTime()
);
});
});
});

View File

@@ -0,0 +1,425 @@
import { describe, it, expect } from 'vitest';
import { validateMaintenanceVisit } from '../validation';
describe('Maintenance Visit Validation', () => {
describe('validateMaintenanceVisit', () => {
it('should validate a complete maintenance visit successfully', () => {
const validData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك والفلتر',
cost: 150.50,
paymentStatus: 'paid',
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(validData);
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
it('should skip validation for missing vehicleId (partial validation)', () => {
const partialData = {
customerId: 1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 150.00,
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(partialData);
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
it('should reject invalid vehicleId', () => {
const invalidData = {
vehicleId: 0,
customerId: 1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 150.00,
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(invalidData);
expect(result.isValid).toBe(false);
expect(result.errors.vehicleId).toBe('المركبة مطلوبة');
});
it('should skip validation for missing customerId (partial validation)', () => {
const partialData = {
vehicleId: 1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 150.00,
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(partialData);
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
it('should reject invalid customerId', () => {
const invalidData = {
vehicleId: 1,
customerId: -1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 150.00,
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(invalidData);
expect(result.isValid).toBe(false);
expect(result.errors.customerId).toBe('العميل مطلوب');
});
it('should skip validation for missing maintenanceType (partial validation)', () => {
const partialData = {
vehicleId: 1,
customerId: 1,
description: 'تغيير زيت المحرك',
cost: 150.00,
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(partialData);
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
it('should reject empty maintenanceType', () => {
const invalidData = {
vehicleId: 1,
customerId: 1,
maintenanceType: ' ',
description: 'تغيير زيت المحرك',
cost: 150.00,
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(invalidData);
expect(result.isValid).toBe(false);
expect(result.errors.maintenanceType).toBe('نوع الصيانة مطلوب');
});
it('should skip validation for missing description (partial validation)', () => {
const partialData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'تغيير زيت',
cost: 150.00,
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(partialData);
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
it('should reject empty description', () => {
const invalidData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'تغيير زيت',
description: '',
cost: 150.00,
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(invalidData);
expect(result.isValid).toBe(false);
expect(result.errors.description).toBe('وصف الصيانة مطلوب');
});
it('should reject description that is too long', () => {
const longDescription = 'أ'.repeat(501); // 501 characters
const invalidData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'تغيير زيت',
description: longDescription,
cost: 150.00,
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(invalidData);
expect(result.isValid).toBe(false);
expect(result.errors.description).toBe('الوصف يجب أن يكون أقل من 500 حرف');
});
it('should skip validation for missing cost (partial validation)', () => {
const partialData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(partialData);
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
it('should reject negative cost', () => {
const invalidData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: -10.00,
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(invalidData);
expect(result.isValid).toBe(false);
expect(result.errors.cost).toBe('التكلفة يجب أن تكون بين 0 و 999999.99');
});
it('should reject cost that is too high', () => {
const invalidData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 1000000.00,
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(invalidData);
expect(result.isValid).toBe(false);
expect(result.errors.cost).toBe('التكلفة يجب أن تكون بين 0 و 999999.99');
});
it('should validate payment status', () => {
const invalidData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 150.00,
paymentStatus: 'invalid_status',
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(invalidData);
expect(result.isValid).toBe(false);
expect(result.errors.paymentStatus).toBe('حالة الدفع غير صحيحة');
});
it('should accept valid payment statuses', () => {
const validStatuses = ['pending', 'paid', 'partial', 'cancelled'];
validStatuses.forEach(status => {
const validData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 150.00,
paymentStatus: status,
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(validData);
expect(result.isValid).toBe(true);
});
});
it('should skip validation for missing kilometers (partial validation)', () => {
const partialData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 150.00,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(partialData);
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
it('should reject negative kilometers', () => {
const invalidData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 150.00,
kilometers: -1000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(invalidData);
expect(result.isValid).toBe(false);
expect(result.errors.kilometers).toBe('عدد الكيلومترات يجب أن يكون رقم موجب');
});
it('should accept zero kilometers', () => {
const validData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 150.00,
kilometers: 0,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(validData);
expect(result.isValid).toBe(true);
});
it('should skip validation for missing nextVisitDelay (partial validation)', () => {
const partialData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 150.00,
kilometers: 50000,
};
const result = validateMaintenanceVisit(partialData);
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
it('should accept valid nextVisitDelay values', () => {
const validDelays = [1, 2, 3, 4];
validDelays.forEach(delay => {
const validData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 150.00,
kilometers: 50000,
nextVisitDelay: delay,
};
const result = validateMaintenanceVisit(validData);
expect(result.isValid).toBe(true);
});
});
it('should reject invalid nextVisitDelay values', () => {
const invalidDelays = [0, 5, 6, -1];
invalidDelays.forEach(delay => {
const invalidData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 150.00,
kilometers: 50000,
nextVisitDelay: delay,
};
const result = validateMaintenanceVisit(invalidData);
expect(result.isValid).toBe(false);
expect(result.errors.nextVisitDelay).toBe('فترة الزيارة التالية يجب أن تكون 1، 2، 3، أو 4 أشهر');
});
});
it('should handle partial validation for updates', () => {
const partialData = {
maintenanceType: 'فحص دوري',
cost: 200.00,
};
const result = validateMaintenanceVisit(partialData);
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
it('should validate partial data with errors', () => {
const partialData = {
maintenanceType: '',
cost: -50.00,
paymentStatus: 'invalid',
};
const result = validateMaintenanceVisit(partialData);
expect(result.isValid).toBe(false);
expect(result.errors.maintenanceType).toBe('نوع الصيانة مطلوب');
expect(result.errors.cost).toBe('التكلفة يجب أن تكون بين 0 و 999999.99');
expect(result.errors.paymentStatus).toBe('حالة الدفع غير صحيحة');
});
it('should accept zero cost', () => {
const validData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'فحص مجاني',
description: 'فحص مجاني للعميل',
cost: 0,
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(validData);
expect(result.isValid).toBe(true);
});
it('should accept decimal cost values', () => {
const validData = {
vehicleId: 1,
customerId: 1,
maintenanceType: 'تغيير زيت',
description: 'تغيير زيت المحرك',
cost: 150.75,
kilometers: 50000,
nextVisitDelay: 3,
};
const result = validateMaintenanceVisit(validData);
expect(result.isValid).toBe(true);
});
});
});

View File

@@ -0,0 +1,107 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { checkPermission, createUnauthorizedResponse } from "../auth-middleware.server";
import { AUTH_LEVELS, USER_STATUS } from "~/types/auth";
import type { SafeUser } from "~/types/auth";
// Mock user data for testing permissions
const mockSuperAdmin: SafeUser = {
id: 1,
name: "Super Admin",
username: "superadmin",
email: "super@example.com",
status: USER_STATUS.ACTIVE,
authLevel: AUTH_LEVELS.SUPERADMIN,
createdDate: new Date(),
editDate: new Date(),
};
const mockAdmin: SafeUser = {
id: 2,
name: "Admin User",
username: "admin",
email: "admin@example.com",
status: USER_STATUS.ACTIVE,
authLevel: AUTH_LEVELS.ADMIN,
createdDate: new Date(),
editDate: new Date(),
};
const mockUser: SafeUser = {
id: 3,
name: "Regular User",
username: "user",
email: "user@example.com",
status: USER_STATUS.ACTIVE,
authLevel: AUTH_LEVELS.USER,
createdDate: new Date(),
editDate: new Date(),
};
describe("Route Protection Integration Tests", () => {
describe("checkPermission", () => {
it("should correctly check view_all_users permission", () => {
expect(checkPermission(mockSuperAdmin, "view_all_users")).toBe(true);
expect(checkPermission(mockAdmin, "view_all_users")).toBe(false);
expect(checkPermission(mockUser, "view_all_users")).toBe(false);
});
it("should correctly check create_users permission", () => {
expect(checkPermission(mockSuperAdmin, "create_users")).toBe(true);
expect(checkPermission(mockAdmin, "create_users")).toBe(true);
expect(checkPermission(mockUser, "create_users")).toBe(false);
});
it("should correctly check manage_finances permission", () => {
expect(checkPermission(mockSuperAdmin, "manage_finances")).toBe(true);
expect(checkPermission(mockAdmin, "manage_finances")).toBe(true);
expect(checkPermission(mockUser, "manage_finances")).toBe(false);
});
it("should correctly check view_reports permission", () => {
expect(checkPermission(mockSuperAdmin, "view_reports")).toBe(true);
expect(checkPermission(mockAdmin, "view_reports")).toBe(true);
expect(checkPermission(mockUser, "view_reports")).toBe(false);
});
it("should return false for unknown permission", () => {
expect(checkPermission(mockUser, "unknown_permission" as any)).toBe(false);
expect(checkPermission(mockAdmin, "unknown_permission" as any)).toBe(false);
expect(checkPermission(mockSuperAdmin, "unknown_permission" as any)).toBe(false);
});
});
describe("createUnauthorizedResponse", () => {
it("should create response with default message", () => {
const response = createUnauthorizedResponse();
expect(response.status).toBe(403);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
});
it("should create response with custom message", () => {
const customMessage = "Custom error message";
const response = createUnauthorizedResponse(customMessage);
expect(response.status).toBe(403);
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
});
});
describe("Auth Level Hierarchy", () => {
it("should have correct auth level values", () => {
expect(AUTH_LEVELS.SUPERADMIN).toBe(1);
expect(AUTH_LEVELS.ADMIN).toBe(2);
expect(AUTH_LEVELS.USER).toBe(3);
});
it("should enforce correct hierarchy (lower number = higher privilege)", () => {
expect(AUTH_LEVELS.SUPERADMIN < AUTH_LEVELS.ADMIN).toBe(true);
expect(AUTH_LEVELS.ADMIN < AUTH_LEVELS.USER).toBe(true);
});
});
describe("User Status", () => {
it("should have correct status values", () => {
expect(USER_STATUS.ACTIVE).toBe("active");
expect(USER_STATUS.INACTIVE).toBe("inactive");
});
});
});

View File

@@ -0,0 +1,299 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { prisma } from '../db.server';
import { getUsers, createUser, updateUser, deleteUser, toggleUserStatus } from '../user-management.server';
import { hashPassword } from '../auth.server';
import { AUTH_LEVELS, USER_STATUS } from '~/types/auth';
describe('User Management', () => {
beforeEach(async () => {
// Clean up test data
await prisma.user.deleteMany({
where: {
email: {
contains: 'test'
}
}
});
// Ensure the main superadmin exists for login functionality
const existingSuperadmin = await prisma.user.findUnique({
where: { username: 'superadmin' }
});
if (!existingSuperadmin) {
const hashedPassword = await hashPassword('admin123');
await prisma.user.create({
data: {
name: 'Super Administrator',
username: 'superadmin',
email: 'admin@carmaintenance.com',
password: hashedPassword,
status: USER_STATUS.ACTIVE,
authLevel: AUTH_LEVELS.SUPERADMIN,
},
});
}
});
afterEach(async () => {
// Clean up test data but preserve the main superadmin
await prisma.user.deleteMany({
where: {
AND: [
{
email: {
contains: 'test'
}
},
{
username: {
not: 'superadmin'
}
}
]
}
});
});
describe('getUsers', () => {
it('should return users with role-based filtering', async () => {
// Create test users with properly hashed passwords
const hashedPassword = await hashPassword('testpassword123');
const superadmin = await prisma.user.create({
data: {
name: 'Test Superadmin',
username: 'testsuperadmin',
email: 'testsuperadmin@example.com',
password: hashedPassword,
authLevel: AUTH_LEVELS.SUPERADMIN,
status: USER_STATUS.ACTIVE,
}
});
const admin = await prisma.user.create({
data: {
name: 'Test Admin',
username: 'testadmin',
email: 'testadmin@example.com',
password: hashedPassword,
authLevel: AUTH_LEVELS.ADMIN,
status: USER_STATUS.ACTIVE,
}
});
// Superadmin should see all users
const superadminResult = await getUsers(AUTH_LEVELS.SUPERADMIN);
expect(superadminResult.users.length).toBeGreaterThanOrEqual(2);
// Admin should not see superadmin users
const adminResult = await getUsers(AUTH_LEVELS.ADMIN);
const adminVisibleUsers = adminResult.users.filter(u => u.authLevel === AUTH_LEVELS.SUPERADMIN);
expect(adminVisibleUsers.length).toBe(0);
});
it('should support search functionality', async () => {
const hashedPassword = await hashPassword('testpassword123');
await prisma.user.create({
data: {
name: 'John Test User',
username: 'johntestuser',
email: 'johntest@example.com',
password: hashedPassword,
authLevel: AUTH_LEVELS.USER,
status: USER_STATUS.ACTIVE,
}
});
const result = await getUsers(AUTH_LEVELS.SUPERADMIN, 'John');
expect(result.users.some(u => u.name.includes('John'))).toBe(true);
});
});
describe('createUser', () => {
it('should create a new user successfully', async () => {
const userData = {
name: 'New Test User',
username: 'newtestuser',
email: 'newtest@example.com',
password: 'password123',
authLevel: AUTH_LEVELS.USER,
status: USER_STATUS.ACTIVE,
};
const result = await createUser(userData, AUTH_LEVELS.SUPERADMIN);
expect(result.success).toBe(true);
expect(result.user).toBeDefined();
expect(result.user?.name).toBe(userData.name);
expect(result.user?.username).toBe(userData.username);
expect(result.user?.email).toBe(userData.email);
});
it('should prevent admin from creating superadmin', async () => {
const userData = {
name: 'Test Superadmin',
username: 'testsuperadmin2',
email: 'testsuperadmin2@example.com',
password: 'password123',
authLevel: AUTH_LEVELS.SUPERADMIN,
status: USER_STATUS.ACTIVE,
};
const result = await createUser(userData, AUTH_LEVELS.ADMIN);
expect(result.success).toBe(false);
expect(result.error).toContain('لا يمكن للمدير إنشاء حساب مدير عام');
});
it('should prevent duplicate username', async () => {
const userData1 = {
name: 'Test User 1',
username: 'duplicatetest',
email: 'test1@example.com',
password: 'password123',
authLevel: AUTH_LEVELS.USER,
status: USER_STATUS.ACTIVE,
};
const userData2 = {
name: 'Test User 2',
username: 'duplicatetest', // Same username
email: 'test2@example.com',
password: 'password123',
authLevel: AUTH_LEVELS.USER,
status: USER_STATUS.ACTIVE,
};
await createUser(userData1, AUTH_LEVELS.SUPERADMIN);
const result = await createUser(userData2, AUTH_LEVELS.SUPERADMIN);
expect(result.success).toBe(false);
expect(result.error).toContain('اسم المستخدم موجود بالفعل');
});
});
describe('updateUser', () => {
it('should update user successfully', async () => {
const hashedPassword = await hashPassword('testpassword123');
const user = await prisma.user.create({
data: {
name: 'Original Name',
username: 'originaluser' + Date.now(),
email: 'original' + Date.now() + '@example.com',
password: hashedPassword,
authLevel: AUTH_LEVELS.USER,
status: USER_STATUS.ACTIVE,
}
});
const updateData = {
name: 'Updated Name',
email: 'updated' + Date.now() + '@example.com',
};
const result = await updateUser(user.id, updateData, AUTH_LEVELS.SUPERADMIN);
if (!result.success) {
console.log('Update failed:', result.error);
}
expect(result.success).toBe(true);
expect(result.user?.name).toBe('Updated Name');
expect(result.user?.email).toBe(updateData.email);
});
});
describe('toggleUserStatus', () => {
it('should toggle user status', async () => {
const hashedPassword = await hashPassword('testpassword123');
const user = await prisma.user.create({
data: {
name: 'Test User',
username: 'teststatususer' + Date.now(),
email: 'teststatus' + Date.now() + '@example.com',
password: hashedPassword,
authLevel: AUTH_LEVELS.USER,
status: USER_STATUS.ACTIVE,
}
});
const result = await toggleUserStatus(user.id, AUTH_LEVELS.SUPERADMIN);
expect(result.success).toBe(true);
expect(result.user?.status).toBe(USER_STATUS.INACTIVE);
});
});
describe('deleteUser', () => {
it('should delete user successfully', async () => {
const hashedPassword = await hashPassword('testpassword123');
const user = await prisma.user.create({
data: {
name: 'Delete Test User',
username: 'deletetestuser' + Date.now(),
email: 'deletetest' + Date.now() + '@example.com',
password: hashedPassword,
authLevel: AUTH_LEVELS.USER,
status: USER_STATUS.ACTIVE,
}
});
const result = await deleteUser(user.id, AUTH_LEVELS.SUPERADMIN);
expect(result.success).toBe(true);
// Verify user is deleted
const deletedUser = await prisma.user.findUnique({
where: { id: user.id }
});
expect(deletedUser).toBeNull();
});
it('should prevent deletion of last superadmin', async () => {
// Delete any test superadmins but keep the main superadmin
await prisma.user.deleteMany({
where: {
AND: [
{ authLevel: AUTH_LEVELS.SUPERADMIN },
{ username: { not: 'superadmin' } }
]
}
});
// Try to delete the main superadmin (should fail as it's the last one)
const mainSuperadmin = await prisma.user.findUnique({
where: { username: 'superadmin' }
});
if (mainSuperadmin) {
const result = await deleteUser(mainSuperadmin.id, AUTH_LEVELS.SUPERADMIN);
expect(result.success).toBe(false);
expect(result.error).toContain('لا يمكن حذف آخر مدير عام في النظام');
} else {
// If main superadmin doesn't exist, create one and test
const hashedPassword = await hashPassword('admin123');
const superadmin = await prisma.user.create({
data: {
name: 'Last Superadmin',
username: 'lastsuperadmin' + Date.now(),
email: 'lastsuperadmin' + Date.now() + '@example.com',
password: hashedPassword,
authLevel: AUTH_LEVELS.SUPERADMIN,
status: USER_STATUS.ACTIVE,
}
});
const result = await deleteUser(superadmin.id, AUTH_LEVELS.SUPERADMIN);
expect(result.success).toBe(false);
expect(result.error).toContain('لا يمكن حذف آخر مدير عام في النظام');
}
});
});
});

View File

@@ -0,0 +1,222 @@
import { describe, it, expect } from 'vitest';
import {
validateField,
validateFields,
validateEmail,
validatePhone,
validatePassword,
validateUsername,
validatePlateNumber,
validateYear,
validateCurrency,
sanitizeString,
sanitizeNumber,
sanitizeInteger,
sanitizeFormData,
PATTERNS
} from '../validation-utils';
describe('Validation Utils', () => {
describe('validateField', () => {
it('should validate required fields', () => {
const result1 = validateField('', { required: true });
expect(result1.isValid).toBe(false);
expect(result1.error).toBe('هذا الحقل مطلوب');
const result2 = validateField('value', { required: true });
expect(result2.isValid).toBe(true);
});
it('should validate email fields', () => {
const result1 = validateField('invalid-email', { email: true });
expect(result1.isValid).toBe(false);
expect(result1.error).toBe('البريد الإلكتروني غير صحيح');
const result2 = validateField('test@example.com', { email: true });
expect(result2.isValid).toBe(true);
});
it('should validate phone fields', () => {
const result1 = validateField('invalid-phone', { phone: true });
expect(result1.isValid).toBe(false);
expect(result1.error).toBe('رقم الهاتف غير صحيح');
const result2 = validateField('+966501234567', { phone: true });
expect(result2.isValid).toBe(true);
});
it('should validate length constraints', () => {
const result1 = validateField('ab', { minLength: 3 });
expect(result1.isValid).toBe(false);
expect(result1.error).toBe('يجب أن يكون على الأقل 3 أحرف');
const result2 = validateField('a'.repeat(101), { maxLength: 100 });
expect(result2.isValid).toBe(false);
expect(result2.error).toBe('يجب أن يكون أقل من 100 حرف');
});
it('should validate numeric constraints', () => {
const result1 = validateField(5, { min: 10 });
expect(result1.isValid).toBe(false);
expect(result1.error).toBe('يجب أن يكون على الأقل 10');
const result2 = validateField(15, { max: 10 });
expect(result2.isValid).toBe(false);
expect(result2.error).toBe('يجب أن يكون أقل من 10');
});
it('should validate custom rules', () => {
const customRule = (value: any) => {
return value === 'forbidden' ? 'هذه القيمة غير مسموحة' : null;
};
const result1 = validateField('forbidden', { custom: customRule });
expect(result1.isValid).toBe(false);
expect(result1.error).toBe('هذه القيمة غير مسموحة');
const result2 = validateField('allowed', { custom: customRule });
expect(result2.isValid).toBe(true);
});
});
describe('validateFields', () => {
it('should validate multiple fields', () => {
const data = {
name: '',
email: 'invalid-email',
age: 5,
};
const rules = {
name: { required: true },
email: { email: true },
age: { min: 18 },
};
const result = validateFields(data, rules);
expect(result.isValid).toBe(false);
expect(result.errors.name).toBe('هذا الحقل مطلوب');
expect(result.errors.email).toBe('البريد الإلكتروني غير صحيح');
expect(result.errors.age).toBe('يجب أن يكون على الأقل 18');
});
});
describe('specific validation functions', () => {
it('should validate email', () => {
const result1 = validateEmail('');
expect(result1.isValid).toBe(false);
const result2 = validateEmail('test@example.com');
expect(result2.isValid).toBe(true);
});
it('should validate phone', () => {
const result1 = validatePhone('invalid');
expect(result1.isValid).toBe(false);
const result2 = validatePhone('+966501234567');
expect(result2.isValid).toBe(true);
});
it('should validate password', () => {
const result1 = validatePassword('123');
expect(result1.isValid).toBe(false);
const result2 = validatePassword('password123');
expect(result2.isValid).toBe(true);
});
it('should validate username', () => {
const result1 = validateUsername('ab');
expect(result1.isValid).toBe(false);
const result2 = validateUsername('user123');
expect(result2.isValid).toBe(true);
});
it('should validate plate number', () => {
const result1 = validatePlateNumber('');
expect(result1.isValid).toBe(false);
const result2 = validatePlateNumber('ABC-1234');
expect(result2.isValid).toBe(true);
});
it('should validate year', () => {
const result1 = validateYear(1800);
expect(result1.isValid).toBe(false);
const result2 = validateYear(2023);
expect(result2.isValid).toBe(true);
});
it('should validate currency', () => {
const result1 = validateCurrency(-10);
expect(result1.isValid).toBe(false);
const result2 = validateCurrency(100.50);
expect(result2.isValid).toBe(true);
});
});
describe('sanitization functions', () => {
it('should sanitize strings', () => {
expect(sanitizeString(' hello world ')).toBe('hello world');
expect(sanitizeString('\t\n test \n\t')).toBe('test');
});
it('should sanitize numbers', () => {
expect(sanitizeNumber('123.45')).toBe(123.45);
expect(sanitizeNumber('invalid')).toBe(null);
expect(sanitizeNumber(456)).toBe(456);
});
it('should sanitize integers', () => {
expect(sanitizeInteger('123')).toBe(123);
expect(sanitizeInteger('123.45')).toBe(123);
expect(sanitizeInteger('invalid')).toBe(null);
});
it('should sanitize form data', () => {
const data = {
name: ' John Doe ',
age: 25,
description: '\t\n Some text \n\t',
};
const sanitized = sanitizeFormData(data);
expect(sanitized.name).toBe('John Doe');
expect(sanitized.age).toBe(25);
expect(sanitized.description).toBe('Some text');
});
});
describe('patterns', () => {
it('should match email pattern', () => {
expect(PATTERNS.email.test('test@example.com')).toBe(true);
expect(PATTERNS.email.test('invalid-email')).toBe(false);
});
it('should match phone pattern', () => {
expect(PATTERNS.phone.test('+966501234567')).toBe(true);
expect(PATTERNS.phone.test('0501234567')).toBe(true);
expect(PATTERNS.phone.test('invalid-phone')).toBe(false);
});
it('should match username pattern', () => {
expect(PATTERNS.username.test('user123')).toBe(true);
expect(PATTERNS.username.test('user_name')).toBe(true);
expect(PATTERNS.username.test('user-name')).toBe(false);
expect(PATTERNS.username.test('user name')).toBe(false);
});
it('should match numeric patterns', () => {
expect(PATTERNS.numeric.test('12345')).toBe(true);
expect(PATTERNS.numeric.test('123abc')).toBe(false);
expect(PATTERNS.decimal.test('123.45')).toBe(true);
expect(PATTERNS.decimal.test('123')).toBe(true);
expect(PATTERNS.decimal.test('123.45.67')).toBe(false);
});
});
});

View File

@@ -0,0 +1,443 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
createVehicle,
updateVehicle,
deleteVehicle,
getVehicles,
getVehicleById,
getVehiclesForSelect,
searchVehicles,
getVehicleStats
} from '../vehicle-management.server';
import { prisma } from '../db.server';
// Mock Prisma
vi.mock('../db.server', () => ({
prisma: {
vehicle: {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
customer: {
findUnique: vi.fn(),
},
},
}));
describe('Vehicle Management', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.resetAllMocks();
});
describe('createVehicle', () => {
const validVehicleData = {
plateNumber: 'ABC-123',
bodyType: 'سيدان',
manufacturer: 'تويوتا',
model: 'كامري',
year: 2020,
transmission: 'Automatic',
fuel: 'Gasoline',
useType: 'personal',
ownerId: 1,
};
it('should create a vehicle successfully', async () => {
const mockVehicle = { id: 1, ...validVehicleData };
const mockCustomer = { id: 1, name: 'أحمد محمد' };
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
(prisma.customer.findUnique as any).mockResolvedValue(mockCustomer);
(prisma.vehicle.create as any).mockResolvedValue(mockVehicle);
const result = await createVehicle(validVehicleData);
expect(result.success).toBe(true);
expect(result.vehicle).toEqual(mockVehicle);
expect(prisma.vehicle.create).toHaveBeenCalledWith({
data: expect.objectContaining({
plateNumber: 'ABC-123',
manufacturer: 'تويوتا',
model: 'كامري',
ownerId: 1,
}),
});
});
it('should fail if plate number already exists', async () => {
const existingVehicle = { id: 2, plateNumber: 'ABC-123' };
(prisma.vehicle.findUnique as any).mockResolvedValue(existingVehicle);
const result = await createVehicle(validVehicleData);
expect(result.success).toBe(false);
expect(result.error).toBe('رقم اللوحة موجود بالفعل');
expect(prisma.vehicle.create).not.toHaveBeenCalled();
});
it('should fail if owner does not exist', async () => {
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
(prisma.customer.findUnique as any).mockResolvedValue(null);
const result = await createVehicle(validVehicleData);
expect(result.success).toBe(false);
expect(result.error).toBe('المالك غير موجود');
expect(prisma.vehicle.create).not.toHaveBeenCalled();
});
it('should handle database errors', async () => {
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
(prisma.customer.findUnique as any).mockResolvedValue({ id: 1 });
(prisma.vehicle.create as any).mockRejectedValue(new Error('Database error'));
const result = await createVehicle(validVehicleData);
expect(result.success).toBe(false);
expect(result.error).toBe('حدث خطأ أثناء إنشاء المركبة');
});
});
describe('updateVehicle', () => {
const updateData = {
plateNumber: 'XYZ-789',
manufacturer: 'هوندا',
model: 'أكورد',
};
it('should update a vehicle successfully', async () => {
const existingVehicle = { id: 1, plateNumber: 'ABC-123', ownerId: 1 };
const updatedVehicle = { ...existingVehicle, ...updateData };
(prisma.vehicle.findUnique as any).mockResolvedValue(existingVehicle);
(prisma.vehicle.findFirst as any).mockResolvedValue(null);
(prisma.vehicle.update as any).mockResolvedValue(updatedVehicle);
const result = await updateVehicle(1, updateData);
expect(result.success).toBe(true);
expect(result.vehicle).toEqual(updatedVehicle);
expect(prisma.vehicle.update).toHaveBeenCalledWith({
where: { id: 1 },
data: expect.objectContaining({
plateNumber: 'XYZ-789',
manufacturer: 'هوندا',
model: 'أكورد',
}),
});
});
it('should fail if vehicle does not exist', async () => {
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
const result = await updateVehicle(999, updateData);
expect(result.success).toBe(false);
expect(result.error).toBe('المركبة غير موجودة');
expect(prisma.vehicle.update).not.toHaveBeenCalled();
});
it('should fail if new plate number conflicts', async () => {
const existingVehicle = { id: 1, plateNumber: 'ABC-123' };
const conflictVehicle = { id: 2, plateNumber: 'XYZ-789' };
(prisma.vehicle.findUnique as any).mockResolvedValue(existingVehicle);
(prisma.vehicle.findFirst as any).mockResolvedValue(conflictVehicle);
const result = await updateVehicle(1, updateData);
expect(result.success).toBe(false);
expect(result.error).toBe('رقم اللوحة موجود بالفعل');
expect(prisma.vehicle.update).not.toHaveBeenCalled();
});
});
describe('deleteVehicle', () => {
it('should delete a vehicle successfully', async () => {
const existingVehicle = {
id: 1,
plateNumber: 'ABC-123',
maintenanceVisits: []
};
(prisma.vehicle.findUnique as any).mockResolvedValue(existingVehicle);
(prisma.vehicle.delete as any).mockResolvedValue(existingVehicle);
const result = await deleteVehicle(1);
expect(result.success).toBe(true);
expect(prisma.vehicle.delete).toHaveBeenCalledWith({
where: { id: 1 },
});
});
it('should fail if vehicle does not exist', async () => {
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
const result = await deleteVehicle(999);
expect(result.success).toBe(false);
expect(result.error).toBe('المركبة غير موجودة');
expect(prisma.vehicle.delete).not.toHaveBeenCalled();
});
it('should fail if vehicle has maintenance visits', async () => {
const existingVehicle = {
id: 1,
plateNumber: 'ABC-123',
maintenanceVisits: [{ id: 1 }, { id: 2 }]
};
(prisma.vehicle.findUnique as any).mockResolvedValue(existingVehicle);
const result = await deleteVehicle(1);
expect(result.success).toBe(false);
expect(result.error).toContain('لا يمكن حذف المركبة لأنها تحتوي على 2 زيارة صيانة');
expect(prisma.vehicle.delete).not.toHaveBeenCalled();
});
});
describe('getVehicles', () => {
it('should return vehicles with pagination', async () => {
const mockVehicles = [
{
id: 1,
plateNumber: 'ABC-123',
manufacturer: 'تويوتا',
owner: { id: 1, name: 'أحمد محمد' }
},
{
id: 2,
plateNumber: 'XYZ-789',
manufacturer: 'هوندا',
owner: { id: 2, name: 'محمد علي' }
}
];
(prisma.vehicle.findMany as any).mockResolvedValue(mockVehicles);
(prisma.vehicle.count as any).mockResolvedValue(2);
const result = await getVehicles('', 1, 10);
expect(result.vehicles).toEqual(mockVehicles);
expect(result.total).toBe(2);
expect(result.totalPages).toBe(1);
expect(prisma.vehicle.findMany).toHaveBeenCalledWith({
where: {},
include: {
owner: {
select: {
id: true,
name: true,
phone: true,
email: true,
},
},
},
orderBy: { createdDate: 'desc' },
skip: 0,
take: 10,
});
});
it('should filter by search query', async () => {
(prisma.vehicle.findMany as any).mockResolvedValue([]);
(prisma.vehicle.count as any).mockResolvedValue(0);
await getVehicles('تويوتا', 1, 10);
expect(prisma.vehicle.findMany).toHaveBeenCalledWith({
where: {
OR: [
{ plateNumber: { contains: 'تويوتا' } },
{ manufacturer: { contains: 'تويوتا' } },
{ model: { contains: 'تويوتا' } },
{ bodyType: { contains: 'تويوتا' } },
{ owner: { name: { contains: 'تويوتا' } } },
],
},
include: expect.any(Object),
orderBy: { createdDate: 'desc' },
skip: 0,
take: 10,
});
});
it('should filter by owner ID', async () => {
(prisma.vehicle.findMany as any).mockResolvedValue([]);
(prisma.vehicle.count as any).mockResolvedValue(0);
await getVehicles('', 1, 10, 5);
expect(prisma.vehicle.findMany).toHaveBeenCalledWith({
where: { ownerId: 5 },
include: expect.any(Object),
orderBy: { createdDate: 'desc' },
skip: 0,
take: 10,
});
});
});
describe('getVehicleById', () => {
it('should return vehicle with full relationships', async () => {
const mockVehicle = {
id: 1,
plateNumber: 'ABC-123',
owner: { id: 1, name: 'أحمد محمد' },
maintenanceVisits: [
{ id: 1, visitDate: new Date(), cost: 100 }
]
};
(prisma.vehicle.findUnique as any).mockResolvedValue(mockVehicle);
const result = await getVehicleById(1);
expect(result).toEqual(mockVehicle);
expect(prisma.vehicle.findUnique).toHaveBeenCalledWith({
where: { id: 1 },
include: {
owner: true,
maintenanceVisits: {
include: {
customer: {
select: {
id: true,
name: true,
phone: true,
},
},
},
orderBy: { visitDate: 'desc' },
take: 10,
},
},
});
});
it('should return null if vehicle not found', async () => {
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
const result = await getVehicleById(999);
expect(result).toBeNull();
});
});
describe('getVehicleStats', () => {
it('should calculate vehicle statistics', async () => {
const mockVehicle = {
id: 1,
suggestedNextVisitDate: new Date('2024-06-01'),
maintenanceVisits: [
{ cost: 150, visitDate: new Date('2024-03-01') }, // Most recent first (desc order)
{ cost: 200, visitDate: new Date('2024-02-01') },
{ cost: 100, visitDate: new Date('2024-01-01') },
]
};
(prisma.vehicle.findUnique as any).mockResolvedValue(mockVehicle);
const result = await getVehicleStats(1);
expect(result).toEqual({
totalVisits: 3,
totalSpent: 450,
lastVisitDate: new Date('2024-03-01'),
nextSuggestedVisitDate: new Date('2024-06-01'),
averageVisitCost: 150,
});
});
it('should return null if vehicle not found', async () => {
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
const result = await getVehicleStats(999);
expect(result).toBeNull();
});
it('should handle vehicle with no visits', async () => {
const mockVehicle = {
id: 1,
suggestedNextVisitDate: null,
maintenanceVisits: []
};
(prisma.vehicle.findUnique as any).mockResolvedValue(mockVehicle);
const result = await getVehicleStats(1);
expect(result).toEqual({
totalVisits: 0,
totalSpent: 0,
lastVisitDate: undefined,
nextSuggestedVisitDate: undefined,
averageVisitCost: 0,
});
});
});
describe('searchVehicles', () => {
it('should search vehicles by query', async () => {
const mockVehicles = [
{
id: 1,
plateNumber: 'ABC-123',
manufacturer: 'تويوتا',
model: 'كامري',
year: 2020,
owner: { id: 1, name: 'أحمد محمد' }
}
];
(prisma.vehicle.findMany as any).mockResolvedValue(mockVehicles);
const result = await searchVehicles('تويوتا');
expect(result).toEqual(mockVehicles);
expect(prisma.vehicle.findMany).toHaveBeenCalledWith({
where: {
OR: [
{ plateNumber: { contains: 'تويوتا' } },
{ manufacturer: { contains: 'تويوتا' } },
{ model: { contains: 'تويوتا' } },
],
},
select: {
id: true,
plateNumber: true,
manufacturer: true,
model: true,
year: true,
owner: {
select: {
id: true,
name: true,
},
},
},
orderBy: { plateNumber: 'asc' },
take: 10,
});
});
it('should return empty array for short queries', async () => {
const result = await searchVehicles('a');
expect(result).toEqual([]);
expect(prisma.vehicle.findMany).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,379 @@
import { describe, it, expect } from 'vitest';
import { validateVehicle } from '../validation';
describe('Vehicle Validation', () => {
describe('validateVehicle', () => {
const validVehicleData = {
plateNumber: 'ABC-123',
bodyType: 'سيدان',
manufacturer: 'تويوتا',
model: 'كامري',
year: 2020,
transmission: 'Automatic',
fuel: 'Gasoline',
useType: 'personal',
ownerId: 1,
};
it('should validate a complete valid vehicle', () => {
const result = validateVehicle(validVehicleData);
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
it('should validate a minimal valid vehicle', () => {
const minimalData = {
plateNumber: 'ABC-123',
bodyType: 'سيدان',
manufacturer: 'تويوتا',
model: 'كامري',
year: 2020,
transmission: 'Automatic',
fuel: 'Gasoline',
useType: 'personal',
ownerId: 1,
};
const result = validateVehicle(minimalData);
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
describe('plateNumber validation', () => {
it('should require plate number', () => {
const result = validateVehicle({ ...validVehicleData, plateNumber: '' });
expect(result.isValid).toBe(false);
expect(result.errors.plateNumber).toBe('رقم اللوحة مطلوب');
});
it('should require plate number to not be just whitespace', () => {
const result = validateVehicle({ ...validVehicleData, plateNumber: ' ' });
expect(result.isValid).toBe(false);
expect(result.errors.plateNumber).toBe('رقم اللوحة مطلوب');
});
});
describe('bodyType validation', () => {
it('should require body type', () => {
const result = validateVehicle({ ...validVehicleData, bodyType: '' });
expect(result.isValid).toBe(false);
expect(result.errors.bodyType).toBe('نوع الهيكل مطلوب');
});
it('should require body type to not be just whitespace', () => {
const result = validateVehicle({ ...validVehicleData, bodyType: ' ' });
expect(result.isValid).toBe(false);
expect(result.errors.bodyType).toBe('نوع الهيكل مطلوب');
});
});
describe('manufacturer validation', () => {
it('should require manufacturer', () => {
const result = validateVehicle({ ...validVehicleData, manufacturer: '' });
expect(result.isValid).toBe(false);
expect(result.errors.manufacturer).toBe('الشركة المصنعة مطلوبة');
});
it('should require manufacturer to not be just whitespace', () => {
const result = validateVehicle({ ...validVehicleData, manufacturer: ' ' });
expect(result.isValid).toBe(false);
expect(result.errors.manufacturer).toBe('الشركة المصنعة مطلوبة');
});
});
describe('model validation', () => {
it('should require model', () => {
const result = validateVehicle({ ...validVehicleData, model: '' });
expect(result.isValid).toBe(false);
expect(result.errors.model).toBe('الموديل مطلوب');
});
it('should require model to not be just whitespace', () => {
const result = validateVehicle({ ...validVehicleData, model: ' ' });
expect(result.isValid).toBe(false);
expect(result.errors.model).toBe('الموديل مطلوب');
});
});
describe('year validation', () => {
it('should not validate undefined year (partial validation)', () => {
const result = validateVehicle({ ...validVehicleData, year: undefined as any });
expect(result.isValid).toBe(true);
expect(result.errors.year).toBeUndefined();
});
it('should reject year below minimum', () => {
const result = validateVehicle({ ...validVehicleData, year: 1989 });
expect(result.isValid).toBe(false);
expect(result.errors.year).toContain('السنة يجب أن تكون بين 1990');
});
it('should reject year above maximum', () => {
const currentYear = new Date().getFullYear();
const result = validateVehicle({ ...validVehicleData, year: currentYear + 2 });
expect(result.isValid).toBe(false);
expect(result.errors.year).toContain('السنة يجب أن تكون بين');
});
it('should accept current year', () => {
const currentYear = new Date().getFullYear();
const result = validateVehicle({ ...validVehicleData, year: currentYear });
expect(result.isValid).toBe(true);
});
it('should accept next year', () => {
const nextYear = new Date().getFullYear() + 1;
const result = validateVehicle({ ...validVehicleData, year: nextYear });
expect(result.isValid).toBe(true);
});
});
describe('transmission validation', () => {
it('should require transmission', () => {
const result = validateVehicle({ ...validVehicleData, transmission: '' });
expect(result.isValid).toBe(false);
expect(result.errors.transmission).toBe('نوع ناقل الحركة غير صحيح');
});
it('should accept valid transmission types', () => {
const automaticResult = validateVehicle({ ...validVehicleData, transmission: 'Automatic' });
const manualResult = validateVehicle({ ...validVehicleData, transmission: 'Manual' });
expect(automaticResult.isValid).toBe(true);
expect(manualResult.isValid).toBe(true);
});
it('should reject invalid transmission types', () => {
const result = validateVehicle({ ...validVehicleData, transmission: 'CVT' });
expect(result.isValid).toBe(false);
expect(result.errors.transmission).toBe('نوع ناقل الحركة غير صحيح');
});
});
describe('fuel validation', () => {
it('should require fuel type', () => {
const result = validateVehicle({ ...validVehicleData, fuel: '' });
expect(result.isValid).toBe(false);
expect(result.errors.fuel).toBe('نوع الوقود غير صحيح');
});
it('should accept valid fuel types', () => {
const validFuels = ['Gasoline', 'Diesel', 'Hybrid', 'Mild Hybrid', 'Electric'];
validFuels.forEach(fuel => {
const result = validateVehicle({ ...validVehicleData, fuel });
expect(result.isValid).toBe(true);
});
});
it('should reject invalid fuel types', () => {
const result = validateVehicle({ ...validVehicleData, fuel: 'Nuclear' });
expect(result.isValid).toBe(false);
expect(result.errors.fuel).toBe('نوع الوقود غير صحيح');
});
});
describe('cylinders validation', () => {
it('should accept valid cylinder counts', () => {
const validCylinders = [1, 2, 3, 4, 6, 8, 10, 12];
validCylinders.forEach(cylinders => {
const result = validateVehicle({ ...validVehicleData, cylinders });
expect(result.isValid).toBe(true);
});
});
it('should reject cylinder count below minimum', () => {
const result = validateVehicle({ ...validVehicleData, cylinders: 0 });
expect(result.isValid).toBe(false);
expect(result.errors.cylinders).toContain('عدد الأسطوانات يجب أن يكون بين 1');
});
it('should reject cylinder count above maximum', () => {
const result = validateVehicle({ ...validVehicleData, cylinders: 16 });
expect(result.isValid).toBe(false);
expect(result.errors.cylinders).toContain('عدد الأسطوانات يجب أن يكون بين 1 و 12');
});
it('should accept null/undefined cylinders', () => {
const result1 = validateVehicle({ ...validVehicleData, cylinders: undefined });
const result2 = validateVehicle({ ...validVehicleData, cylinders: null as any });
expect(result1.isValid).toBe(true);
expect(result2.isValid).toBe(true);
});
});
describe('engineDisplacement validation', () => {
it('should accept valid engine displacements', () => {
const validDisplacements = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0];
validDisplacements.forEach(engineDisplacement => {
const result = validateVehicle({ ...validVehicleData, engineDisplacement });
expect(result.isValid).toBe(true);
});
});
it('should reject engine displacement at zero', () => {
const result = validateVehicle({ ...validVehicleData, engineDisplacement: 0 });
expect(result.isValid).toBe(false);
expect(result.errors.engineDisplacement).toContain('سعة المحرك يجب أن تكون بين 0.1');
});
it('should reject engine displacement above maximum', () => {
const result = validateVehicle({ ...validVehicleData, engineDisplacement: 15.0 });
expect(result.isValid).toBe(false);
expect(result.errors.engineDisplacement).toContain('سعة المحرك يجب أن تكون بين 0.1 و 10');
});
it('should accept null/undefined engine displacement', () => {
const result1 = validateVehicle({ ...validVehicleData, engineDisplacement: undefined });
const result2 = validateVehicle({ ...validVehicleData, engineDisplacement: null as any });
expect(result1.isValid).toBe(true);
expect(result2.isValid).toBe(true);
});
});
describe('useType validation', () => {
it('should require use type', () => {
const result = validateVehicle({ ...validVehicleData, useType: '' });
expect(result.isValid).toBe(false);
expect(result.errors.useType).toBe('نوع الاستخدام غير صحيح');
});
it('should accept valid use types', () => {
const validUseTypes = ['personal', 'taxi', 'apps', 'loading', 'travel'];
validUseTypes.forEach(useType => {
const result = validateVehicle({ ...validVehicleData, useType });
expect(result.isValid).toBe(true);
});
});
it('should reject invalid use types', () => {
const result = validateVehicle({ ...validVehicleData, useType: 'military' });
expect(result.isValid).toBe(false);
expect(result.errors.useType).toBe('نوع الاستخدام غير صحيح');
});
});
describe('ownerId validation', () => {
it('should not validate undefined owner ID (partial validation)', () => {
const result = validateVehicle({ ...validVehicleData, ownerId: undefined as any });
expect(result.isValid).toBe(true);
expect(result.errors.ownerId).toBeUndefined();
});
it('should reject zero owner ID', () => {
const result = validateVehicle({ ...validVehicleData, ownerId: 0 });
expect(result.isValid).toBe(false);
expect(result.errors.ownerId).toBe('مالك المركبة مطلوب');
});
it('should reject negative owner ID', () => {
const result = validateVehicle({ ...validVehicleData, ownerId: -1 });
expect(result.isValid).toBe(false);
expect(result.errors.ownerId).toBe('مالك المركبة مطلوب');
});
it('should accept positive owner ID', () => {
const result = validateVehicle({ ...validVehicleData, ownerId: 5 });
expect(result.isValid).toBe(true);
});
});
describe('multiple validation errors', () => {
it('should return all validation errors', () => {
const invalidData = {
plateNumber: '',
bodyType: '',
manufacturer: '',
model: '',
year: 1980,
transmission: 'Invalid',
fuel: 'Invalid',
cylinders: 20,
engineDisplacement: 15.0,
useType: 'Invalid',
ownerId: 0,
};
const result = validateVehicle(invalidData);
expect(result.isValid).toBe(false);
expect(Object.keys(result.errors)).toContain('plateNumber');
expect(Object.keys(result.errors)).toContain('bodyType');
expect(Object.keys(result.errors)).toContain('manufacturer');
expect(Object.keys(result.errors)).toContain('model');
expect(Object.keys(result.errors)).toContain('year');
expect(Object.keys(result.errors)).toContain('transmission');
expect(Object.keys(result.errors)).toContain('fuel');
expect(Object.keys(result.errors)).toContain('cylinders');
expect(Object.keys(result.errors)).toContain('engineDisplacement');
expect(Object.keys(result.errors)).toContain('useType');
expect(Object.keys(result.errors)).toContain('ownerId');
});
});
describe('partial validation', () => {
it('should validate only provided fields', () => {
const partialData = {
plateNumber: 'XYZ-789',
year: 2021,
};
const result = validateVehicle(partialData);
expect(result.isValid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
it('should validate only provided fields with errors', () => {
const partialData = {
plateNumber: '',
year: 1980,
cylinders: 20,
};
const result = validateVehicle(partialData);
expect(result.isValid).toBe(false);
expect(Object.keys(result.errors)).toContain('plateNumber');
expect(Object.keys(result.errors)).toContain('year');
expect(Object.keys(result.errors)).toContain('cylinders');
expect(Object.keys(result.errors)).not.toContain('manufacturer');
expect(Object.keys(result.errors)).not.toContain('model');
});
});
});
});

51
app/lib/auth-constants.ts Normal file
View File

@@ -0,0 +1,51 @@
// Authentication configuration constants
export const AUTH_CONFIG = {
// Password requirements
MIN_PASSWORD_LENGTH: 6,
MAX_PASSWORD_LENGTH: 128,
// Session configuration
SESSION_MAX_AGE: 60 * 60 * 24 * 30, // 30 days in seconds
// Rate limiting (for future implementation)
MAX_LOGIN_ATTEMPTS: 5,
LOGIN_ATTEMPT_WINDOW: 15 * 60 * 1000, // 15 minutes in milliseconds
// Cookie configuration
COOKIE_NAME: "car_maintenance_session",
} as const;
// Authentication error messages in Arabic
export const AUTH_ERRORS = {
INVALID_CREDENTIALS: "اسم المستخدم أو كلمة المرور غير صحيحة",
ACCOUNT_INACTIVE: "الحساب غير مفعل",
ACCOUNT_NOT_FOUND: "الحساب غير موجود",
USERNAME_REQUIRED: "اسم المستخدم مطلوب",
EMAIL_REQUIRED: "البريد الإلكتروني مطلوب",
PASSWORD_REQUIRED: "كلمة المرور مطلوبة",
NAME_REQUIRED: "الاسم مطلوب",
PASSWORD_TOO_SHORT: "كلمة المرور يجب أن تكون 6 أحرف على الأقل",
PASSWORD_MISMATCH: "كلمة المرور غير متطابقة",
INVALID_EMAIL: "صيغة البريد الإلكتروني غير صحيحة",
USERNAME_EXISTS: "اسم المستخدم موجود بالفعل",
EMAIL_EXISTS: "البريد الإلكتروني موجود بالفعل",
INSUFFICIENT_PERMISSIONS: "ليس لديك صلاحية للوصول إلى هذه الصفحة",
SESSION_EXPIRED: "انتهت صلاحية الجلسة، يرجى تسجيل الدخول مرة أخرى",
SIGNUP_DISABLED: "التسجيل غير متاح حالياً",
} as const;
// Success messages in Arabic
export const AUTH_SUCCESS = {
LOGIN_SUCCESS: "تم تسجيل الدخول بنجاح",
LOGOUT_SUCCESS: "تم تسجيل الخروج بنجاح",
SIGNUP_SUCCESS: "تم إنشاء الحساب بنجاح",
PASSWORD_CHANGED: "تم تغيير كلمة المرور بنجاح",
PROFILE_UPDATED: "تم تحديث الملف الشخصي بنجاح",
} as const;
// Validation patterns
export const VALIDATION_PATTERNS = {
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
USERNAME: /^[a-zA-Z0-9_]{3,20}$/,
PHONE: /^[0-9+\-\s()]{10,15}$/,
} as const;

View File

@@ -0,0 +1,220 @@
import { redirect } from "@remix-run/node";
import { prisma } from "./db.server";
import { verifyPassword, hashPassword } from "./auth.server";
import type {
SignInFormData,
SignUpFormData,
AuthResult,
AuthLevel,
SafeUser,
RouteProtectionOptions
} from "~/types/auth";
import { AUTH_LEVELS, USER_STATUS } from "~/types/auth";
import { AUTH_ERRORS, AUTH_CONFIG, VALIDATION_PATTERNS } from "./auth-constants";
// Authentication validation functions
export async function validateSignIn(formData: SignInFormData): Promise<AuthResult> {
const { usernameOrEmail, password } = formData;
// Find user by username or email
const user = await prisma.user.findFirst({
where: {
OR: [
{ username: usernameOrEmail },
{ email: usernameOrEmail },
],
},
});
if (!user) {
return {
success: false,
errors: [{ message: AUTH_ERRORS.INVALID_CREDENTIALS }],
};
}
// Check if user is active
if (user.status !== USER_STATUS.ACTIVE) {
return {
success: false,
errors: [{ message: AUTH_ERRORS.ACCOUNT_INACTIVE }],
};
}
// Verify password
const isValidPassword = await verifyPassword(password, user.password);
if (!isValidPassword) {
return {
success: false,
errors: [{ message: AUTH_ERRORS.INVALID_CREDENTIALS }],
};
}
// Return success with safe user data
const { password: _, ...safeUser } = user;
return {
success: true,
user: safeUser,
};
}
export async function validateSignUp(formData: SignUpFormData): Promise<AuthResult> {
const { name, username, email, password, confirmPassword } = formData;
const errors: { field?: string; message: string }[] = [];
// Validate required fields
if (!name.trim()) {
errors.push({ field: "name", message: AUTH_ERRORS.NAME_REQUIRED });
}
if (!username.trim()) {
errors.push({ field: "username", message: AUTH_ERRORS.USERNAME_REQUIRED });
}
if (!email.trim()) {
errors.push({ field: "email", message: AUTH_ERRORS.EMAIL_REQUIRED });
}
if (!password) {
errors.push({ field: "password", message: AUTH_ERRORS.PASSWORD_REQUIRED });
}
if (password !== confirmPassword) {
errors.push({ field: "confirmPassword", message: AUTH_ERRORS.PASSWORD_MISMATCH });
}
// Validate password strength
if (password && password.length < AUTH_CONFIG.MIN_PASSWORD_LENGTH) {
errors.push({ field: "password", message: AUTH_ERRORS.PASSWORD_TOO_SHORT });
}
// Validate email format
if (email && !VALIDATION_PATTERNS.EMAIL.test(email)) {
errors.push({ field: "email", message: AUTH_ERRORS.INVALID_EMAIL });
}
// Check for existing username
if (username) {
const existingUsername = await prisma.user.findUnique({
where: { username },
});
if (existingUsername) {
errors.push({ field: "username", message: AUTH_ERRORS.USERNAME_EXISTS });
}
}
// Check for existing email
if (email) {
const existingEmail = await prisma.user.findUnique({
where: { email },
});
if (existingEmail) {
errors.push({ field: "email", message: AUTH_ERRORS.EMAIL_EXISTS });
}
}
if (errors.length > 0) {
return {
success: false,
errors,
};
}
return { success: true };
}
// User creation function
export async function createUser(formData: SignUpFormData): Promise<SafeUser> {
const { name, username, email, password } = formData;
const hashedPassword = await hashPassword(password);
const user = await prisma.user.create({
data: {
name: name.trim(),
username: username.trim(),
email: email.trim(),
password: hashedPassword,
status: USER_STATUS.ACTIVE,
authLevel: AUTH_LEVELS.ADMIN, // First user becomes admin
createdDate: new Date(),
editDate: new Date(),
},
});
const { password: _, ...safeUser } = user;
return safeUser;
}
// Authorization helper functions
export function hasPermission(userAuthLevel: AuthLevel, requiredAuthLevel: AuthLevel): boolean {
return userAuthLevel <= requiredAuthLevel;
}
export function canAccessUserManagement(userAuthLevel: AuthLevel): boolean {
return userAuthLevel <= AUTH_LEVELS.ADMIN;
}
export function canViewAllUsers(userAuthLevel: AuthLevel): boolean {
return userAuthLevel === AUTH_LEVELS.SUPERADMIN;
}
export function canCreateUsers(userAuthLevel: AuthLevel): boolean {
return userAuthLevel <= AUTH_LEVELS.ADMIN;
}
// Route protection middleware
export async function requireAuthLevel(
request: Request,
requiredAuthLevel: AuthLevel,
options: RouteProtectionOptions = {}
) {
const { allowInactive = false, redirectTo = "/signin" } = options;
// Get user from session
const userId = await getUserId(request);
if (!userId) {
throw redirect(redirectTo);
}
const user = await prisma.user.findUnique({
where: { id: userId },
});
if (!user) {
throw redirect(redirectTo);
}
// Check if user is active (unless explicitly allowed)
if (!allowInactive && user.status !== USER_STATUS.ACTIVE) {
throw redirect("/signin?error=account_inactive");
}
// Check authorization level
if (!hasPermission(user.authLevel as AuthLevel, requiredAuthLevel)) {
throw redirect("/dashboard?error=insufficient_permissions");
}
const { password: _, ...safeUser } = user;
return safeUser;
}
// Check if signup should be allowed (only when no admin users exist)
export async function isSignupAllowed(): Promise<boolean> {
const adminCount = await prisma.user.count({
where: {
authLevel: {
in: [AUTH_LEVELS.SUPERADMIN, AUTH_LEVELS.ADMIN],
},
status: USER_STATUS.ACTIVE,
},
});
return adminCount === 0;
}
// Import getUserId function
async function getUserId(request: Request): Promise<number | null> {
const { getUserId: getSessionUserId } = await import("./auth.server");
return getSessionUserId(request);
}

View File

@@ -0,0 +1,173 @@
import { redirect } from "@remix-run/node";
import { getUser, requireUserId } from "./auth.server";
import { requireAuthLevel } from "./auth-helpers.server";
import type { AuthLevel, SafeUser, RouteProtectionOptions } from "~/types/auth";
import { AUTH_LEVELS, USER_STATUS } from "~/types/auth";
import { AUTH_ERRORS } from "./auth-constants";
// Enhanced middleware for protecting routes that require authentication
export async function requireAuthentication(
request: Request,
options: RouteProtectionOptions = {}
): Promise<SafeUser> {
const { allowInactive = false, redirectTo = "/signin" } = options;
await requireUserId(request, redirectTo);
const user = await getUser(request);
if (!user) {
throw redirect(redirectTo);
}
// Check if user is active (unless explicitly allowed)
if (!allowInactive && user.status !== USER_STATUS.ACTIVE) {
throw redirect("/signin?error=account_inactive");
}
return user;
}
// Middleware for protecting admin routes (admin and superadmin)
export async function requireAdmin(
request: Request,
options: RouteProtectionOptions = {}
): Promise<SafeUser> {
return requireAuthLevel(request, AUTH_LEVELS.ADMIN, options);
}
// Middleware for protecting superadmin routes
export async function requireSuperAdmin(
request: Request,
options: RouteProtectionOptions = {}
): Promise<SafeUser> {
return requireAuthLevel(request, AUTH_LEVELS.SUPERADMIN, options);
}
// Middleware for protecting routes with custom auth level
export async function requireAuth(
request: Request,
authLevel: AuthLevel,
options: RouteProtectionOptions = {}
): Promise<SafeUser> {
return requireAuthLevel(request, authLevel, options);
}
// Middleware for redirecting authenticated users away from auth pages
export async function redirectIfAuthenticated(
request: Request,
redirectTo: string = "/dashboard"
) {
const user = await getUser(request);
if (user && user.status === USER_STATUS.ACTIVE) {
throw redirect(redirectTo);
}
}
// Middleware for optional authentication (user may or may not be logged in)
export async function getOptionalUser(request: Request): Promise<SafeUser | null> {
try {
const user = await getUser(request);
return user && user.status === USER_STATUS.ACTIVE ? user : null;
} catch {
return null;
}
}
// Session validation middleware
export async function validateSession(request: Request): Promise<{
isValid: boolean;
user: SafeUser | null;
error?: string;
}> {
try {
const user = await getUser(request);
if (!user) {
return {
isValid: false,
user: null,
error: "no_user",
};
}
if (user.status !== USER_STATUS.ACTIVE) {
return {
isValid: false,
user: null,
error: "inactive_user",
};
}
return {
isValid: true,
user,
};
} catch {
return {
isValid: false,
user: null,
error: "session_error",
};
}
}
// Route-specific protection functions
export async function protectUserManagementRoute(request: Request): Promise<SafeUser> {
const user = await requireAdmin(request);
// Additional business logic: ensure user can manage users
if (user.authLevel > AUTH_LEVELS.ADMIN) {
throw redirect("/dashboard?error=insufficient_permissions");
}
return user;
}
export async function protectFinancialRoute(request: Request): Promise<SafeUser> {
// Financial routes require at least admin level
return requireAdmin(request);
}
export async function protectCustomerRoute(request: Request): Promise<SafeUser> {
// Customer routes require authentication but any auth level can access
return requireAuthentication(request);
}
export async function protectVehicleRoute(request: Request): Promise<SafeUser> {
// Vehicle routes require authentication but any auth level can access
return requireAuthentication(request);
}
export async function protectMaintenanceRoute(request: Request): Promise<SafeUser> {
// Maintenance routes require authentication but any auth level can access
return requireAuthentication(request);
}
// Utility function to check specific permissions
export function checkPermission(
user: SafeUser,
permission: 'view_all_users' | 'create_users' | 'manage_finances' | 'view_reports'
): boolean {
switch (permission) {
case 'view_all_users':
return user.authLevel === AUTH_LEVELS.SUPERADMIN;
case 'create_users':
return user.authLevel <= AUTH_LEVELS.ADMIN;
case 'manage_finances':
return user.authLevel <= AUTH_LEVELS.ADMIN;
case 'view_reports':
return user.authLevel <= AUTH_LEVELS.ADMIN;
default:
return false;
}
}
// Error handling for unauthorized access
export function createUnauthorizedResponse(message?: string) {
const errorMessage = message || AUTH_ERRORS.INSUFFICIENT_PERMISSIONS;
return new Response(errorMessage, {
status: 403,
headers: {
'Content-Type': 'text/plain; charset=utf-8'
}
});
}

113
app/lib/auth.server.ts Normal file
View File

@@ -0,0 +1,113 @@
import bcrypt from "bcryptjs";
import { createCookieSessionStorage, redirect } from "@remix-run/node";
import { prisma } from "./db.server";
import type { User } from "@prisma/client";
// Session configuration
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) {
throw new Error("SESSION_SECRET must be set");
}
// Create session storage
const storage = createCookieSessionStorage({
cookie: {
name: "car_maintenance_session",
secure: process.env.NODE_ENV === "production",
secrets: [sessionSecret],
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30, // 30 days
httpOnly: true,
},
});
// Password hashing utilities
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
export async function verifyPassword(
password: string,
hashedPassword: string
): Promise<boolean> {
return bcrypt.compare(password, hashedPassword);
}
// Session management functions
export async function createUserSession(
userId: number,
redirectTo: string = "/dashboard"
) {
const session = await storage.getSession();
session.set("userId", userId);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await storage.commitSession(session),
},
});
}
export async function getUserSession(request: Request) {
return storage.getSession(request.headers.get("Cookie"));
}
export async function getUserId(request: Request): Promise<number | null> {
const session = await getUserSession(request);
const userId = session.get("userId");
if (!userId || typeof userId !== "number") return null;
return userId;
}
export async function requireUserId(
request: Request,
redirectTo: string = new URL(request.url).pathname
) {
const userId = await getUserId(request);
if (!userId) {
const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
throw redirect(`/signin?${searchParams}`);
}
return userId;
}
export async function getUser(request: Request): Promise<Omit<User, 'password'> | null> {
const userId = await getUserId(request);
if (!userId) return null;
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
username: true,
email: true,
status: true,
authLevel: true,
createdDate: true,
editDate: true,
},
});
return user;
} catch {
throw logout(request);
}
}
export async function requireUser(request: Request) {
const user = await getUser(request);
if (!user) {
throw logout(request);
}
return user;
}
export async function logout(request: Request) {
const session = await getUserSession(request);
return redirect("/signin", {
headers: {
"Set-Cookie": await storage.destroySession(session),
},
});
}

View File

@@ -0,0 +1,257 @@
import { prisma } from "~/lib/db.server";
import type { CreateCarDatasetData, UpdateCarDatasetData } from "~/types/database";
// Get all unique manufacturers
export async function getManufacturers() {
try {
const manufacturers = await prisma.carDataset.findMany({
where: { isActive: true },
select: { manufacturer: true },
distinct: ['manufacturer'],
orderBy: { manufacturer: 'asc' },
});
return manufacturers.map(item => item.manufacturer);
} catch (error) {
console.error("Error fetching manufacturers:", error);
throw new Error("فشل في جلب الشركات المصنعة");
}
}
// Get all models for a specific manufacturer
export async function getModelsByManufacturer(manufacturer: string) {
try {
const models = await prisma.carDataset.findMany({
where: {
manufacturer,
isActive: true
},
select: { model: true, bodyType: true },
orderBy: { model: 'asc' },
});
return models;
} catch (error) {
console.error("Error fetching models:", error);
throw new Error("فشل في جلب الموديلات");
}
}
// Get body type for a specific manufacturer and model
export async function getBodyType(manufacturer: string, model: string) {
try {
const carData = await prisma.carDataset.findFirst({
where: {
manufacturer,
model,
isActive: true
},
select: { bodyType: true },
});
return carData?.bodyType || null;
} catch (error) {
console.error("Error fetching body type:", error);
throw new Error("فشل في جلب نوع الهيكل");
}
}
// Get all car dataset entries with pagination
export async function getCarDataset(
searchQuery: string = "",
page: number = 1,
limit: number = 50,
manufacturer?: string
) {
try {
const offset = (page - 1) * limit;
const where = {
AND: [
manufacturer ? { manufacturer } : {},
searchQuery ? {
OR: [
{ manufacturer: { contains: searchQuery, mode: 'insensitive' as const } },
{ model: { contains: searchQuery, mode: 'insensitive' as const } },
{ bodyType: { contains: searchQuery, mode: 'insensitive' as const } },
]
} : {}
]
};
const [carDataset, total] = await Promise.all([
prisma.carDataset.findMany({
where,
orderBy: [
{ manufacturer: 'asc' },
{ model: 'asc' }
],
skip: offset,
take: limit,
}),
prisma.carDataset.count({ where })
]);
const totalPages = Math.ceil(total / limit);
return {
carDataset,
total,
totalPages,
};
} catch (error) {
console.error("Error fetching car dataset:", error);
throw new Error("فشل في جلب بيانات السيارات");
}
}
// Create a new car dataset entry
export async function createCarDataset(data: CreateCarDatasetData) {
try {
const carDataset = await prisma.carDataset.create({
data: {
manufacturer: data.manufacturer.trim(),
model: data.model.trim(),
bodyType: data.bodyType.trim(),
isActive: data.isActive ?? true,
},
});
return { success: true, carDataset };
} catch (error: any) {
console.error("Error creating car dataset:", error);
if (error.code === 'P2002') {
return {
success: false,
error: "هذا الموديل موجود بالفعل لهذه الشركة المصنعة"
};
}
return {
success: false,
error: "فشل في إنشاء بيانات السيارة"
};
}
}
// Update a car dataset entry
export async function updateCarDataset(id: number, data: UpdateCarDatasetData) {
try {
const carDataset = await prisma.carDataset.update({
where: { id },
data: {
...(data.manufacturer && { manufacturer: data.manufacturer.trim() }),
...(data.model && { model: data.model.trim() }),
...(data.bodyType && { bodyType: data.bodyType.trim() }),
...(data.isActive !== undefined && { isActive: data.isActive }),
},
});
return { success: true, carDataset };
} catch (error: any) {
console.error("Error updating car dataset:", error);
if (error.code === 'P2002') {
return {
success: false,
error: "هذا الموديل موجود بالفعل لهذه الشركة المصنعة"
};
}
if (error.code === 'P2025') {
return {
success: false,
error: "بيانات السيارة غير موجودة"
};
}
return {
success: false,
error: "فشل في تحديث بيانات السيارة"
};
}
}
// Delete a car dataset entry
export async function deleteCarDataset(id: number) {
try {
await prisma.carDataset.delete({
where: { id },
});
return { success: true };
} catch (error: any) {
console.error("Error deleting car dataset:", error);
if (error.code === 'P2025') {
return {
success: false,
error: "بيانات السيارة غير موجودة"
};
}
return {
success: false,
error: "فشل في حذف بيانات السيارة"
};
}
}
// Get car dataset entry by ID
export async function getCarDatasetById(id: number) {
try {
const carDataset = await prisma.carDataset.findUnique({
where: { id },
});
return carDataset;
} catch (error) {
console.error("Error fetching car dataset by ID:", error);
throw new Error("فشل في جلب بيانات السيارة");
}
}
// Bulk import car dataset
export async function bulkImportCarDataset(data: CreateCarDatasetData[]) {
try {
const results = await prisma.$transaction(async (tx) => {
const created = [];
const errors = [];
for (const item of data) {
try {
const carDataset = await tx.carDataset.create({
data: {
manufacturer: item.manufacturer.trim(),
model: item.model.trim(),
bodyType: item.bodyType.trim(),
isActive: item.isActive ?? true,
},
});
created.push(carDataset);
} catch (error: any) {
if (error.code === 'P2002') {
errors.push(`${item.manufacturer} ${item.model} - موجود بالفعل`);
} else {
errors.push(`${item.manufacturer} ${item.model} - خطأ غير معروف`);
}
}
}
return { created, errors };
});
return {
success: true,
created: results.created.length,
errors: results.errors
};
} catch (error) {
console.error("Error bulk importing car dataset:", error);
return {
success: false,
error: "فشل في استيراد بيانات السيارات"
};
}
}

173
app/lib/constants.ts Normal file
View File

@@ -0,0 +1,173 @@
// Authentication levels
export const AUTH_LEVELS = {
SUPERADMIN: 1,
ADMIN: 2,
USER: 3,
} as const;
export const AUTH_LEVEL_NAMES = {
[AUTH_LEVELS.SUPERADMIN]: 'مدير عام',
[AUTH_LEVELS.ADMIN]: 'مدير',
[AUTH_LEVELS.USER]: 'مستخدم',
} as const;
// User status options
export const USER_STATUS = {
ACTIVE: 'active',
INACTIVE: 'inactive',
} as const;
export const USER_STATUS_NAMES = {
[USER_STATUS.ACTIVE]: 'نشط',
[USER_STATUS.INACTIVE]: 'غير نشط',
} as const;
// Vehicle transmission options
export const TRANSMISSION_TYPES = [
{ value: 'Automatic', label: 'أوتوماتيك' },
{ value: 'Manual', label: 'يدوي' },
] as const;
// Vehicle fuel types
export const FUEL_TYPES = [
{ value: 'Gasoline', label: 'بنزين' },
{ value: 'Diesel', label: 'ديزل' },
{ value: 'Hybrid', label: 'هجين' },
{ value: 'Mild Hybrid', label: 'هجين خفيف' },
{ value: 'Electric', label: 'كهربائي' },
] as const;
// Vehicle use types
export const USE_TYPES = [
{ value: 'personal', label: 'شخصي' },
{ value: 'taxi', label: 'تاكسي' },
{ value: 'apps', label: 'تطبيقات' },
{ value: 'loading', label: 'نقل' },
{ value: 'travel', label: 'سفر' },
] as const;
// Vehicle body types (common in Saudi Arabia)
export const BODY_TYPES = [
{ value: 'سيدان', label: 'سيدان' },
{ value: 'هاتشباك', label: 'هاتشباك' },
{ value: 'SUV', label: 'SUV' },
{ value: 'كروس أوفر', label: 'كروس أوفر' },
{ value: 'بيك أب', label: 'بيك أب' },
{ value: 'كوبيه', label: 'كوبيه' },
{ value: 'كونفرتيبل', label: 'كونفرتيبل' },
{ value: 'فان', label: 'فان' },
{ value: 'شاحنة', label: 'شاحنة' },
] as const;
// Popular car manufacturers in Saudi Arabia
export const MANUFACTURERS = [
{ value: 'تويوتا', label: 'تويوتا' },
{ value: 'هيونداي', label: 'هيونداي' },
{ value: 'نيسان', label: 'نيسان' },
{ value: 'كيا', label: 'كيا' },
{ value: 'هوندا', label: 'هوندا' },
{ value: 'فورد', label: 'فورد' },
{ value: 'شيفروليه', label: 'شيفروليه' },
{ value: ازda', label: ازda' },
{ value: 'ميتسوبيشي', label: 'ميتسوبيشي' },
{ value: 'سوزوكي', label: 'سوزوكي' },
{ value: 'لكزس', label: 'لكزس' },
{ value: 'إنفينيتي', label: 'إنفينيتي' },
{ value: 'جينيسيس', label: 'جينيسيس' },
{ value: 'BMW', label: 'BMW' },
{ value: 'مرسيدس بنز', label: 'مرسيدس بنز' },
{ value: 'أودي', label: 'أودي' },
{ value: 'فولكس واجن', label: 'فولكس واجن' },
{ value: 'جيب', label: 'جيب' },
{ value: 'لاند روفر', label: 'لاند روفر' },
{ value: 'كاديلاك', label: 'كاديلاك' },
{ value: 'لينكولن', label: 'لينكولن' },
{ value: 'جاكوار', label: 'جاكوار' },
{ value: 'بورش', label: 'بورش' },
{ value: 'فيراري', label: 'فيراري' },
{ value: 'لامبورغيني', label: 'لامبورغيني' },
{ value: 'بنتلي', label: 'بنتلي' },
{ value: 'رولز رويس', label: 'رولز رويس' },
{ value: 'أخرى', label: 'أخرى' },
] as const;
// Payment status options
export const PAYMENT_STATUS = {
PENDING: 'pending',
PAID: 'paid',
PARTIAL: 'partial',
CANCELLED: 'cancelled',
} as const;
export const PAYMENT_STATUS_NAMES = {
[PAYMENT_STATUS.PENDING]: 'معلق',
[PAYMENT_STATUS.PAID]: 'مدفوع',
[PAYMENT_STATUS.PARTIAL]: 'مدفوع جزئياً',
[PAYMENT_STATUS.CANCELLED]: 'ملغي',
} as const;
// Maintenance visit delay options (in months)
export const VISIT_DELAY_OPTIONS = [
{ value: 1, label: 'شهر واحد' },
{ value: 2, label: 'شهرين' },
{ value: 3, label: 'ثلاثة أشهر' },
{ value: 4, label: 'أربعة أشهر' },
] as const;
// Common maintenance types
export const MAINTENANCE_TYPES = [
{ value: 'تغيير زيت', label: 'تغيير زيت' },
{ value: 'فحص دوري', label: 'فحص دوري' },
{ value: 'تغيير فلاتر', label: 'تغيير فلاتر' },
{ value: 'فحص فرامل', label: 'فحص فرامل' },
{ value: 'تغيير إطارات', label: 'تغيير إطارات' },
{ value: 'فحص بطارية', label: 'فحص بطارية' },
{ value: 'تنظيف مكيف', label: 'تنظيف مكيف' },
{ value: 'فحص محرك', label: 'فحص محرك' },
{ value: 'تغيير شمعات', label: 'تغيير شمعات' },
{ value: 'فحص ناقل حركة', label: 'فحص ناقل حركة' },
{ value: 'إصلاح عام', label: 'إصلاح عام' },
{ value: 'أخرى', label: 'أخرى' },
] as const;
// Expense categories
export const EXPENSE_CATEGORIES = [
{ value: 'قطع غيار', label: 'قطع غيار' },
{ value: 'أدوات', label: 'أدوات' },
{ value: 'إيجار', label: 'إيجار' },
{ value: 'كهرباء', label: 'كهرباء' },
{ value: 'ماء', label: 'ماء' },
{ value: 'رواتب', label: 'رواتب' },
{ value: 'تأمين', label: 'تأمين' },
{ value: 'وقود', label: 'وقود' },
{ value: 'صيانة معدات', label: 'صيانة معدات' },
{ value: 'تسويق', label: 'تسويق' },
{ value: 'مصاريف إدارية', label: 'مصاريف إدارية' },
{ value: 'أخرى', label: 'أخرى' },
] as const;
// Date format options
export const DATE_FORMATS = {
SHORT: 'dd/MM/yyyy',
LONG: 'dd MMMM yyyy',
WITH_TIME: 'dd/MM/yyyy HH:mm',
} as const;
// Pagination defaults
export const PAGINATION = {
DEFAULT_PAGE_SIZE: 10,
PAGE_SIZE_OPTIONS: [10, 25, 50, 100],
} as const;
// Validation constants
export const VALIDATION = {
MIN_PASSWORD_LENGTH: 6,
MAX_NAME_LENGTH: 100,
MAX_DESCRIPTION_LENGTH: 500,
MIN_YEAR: 1990,
MAX_YEAR: new Date().getFullYear() + 1,
MAX_CYLINDERS: 12,
MAX_ENGINE_DISPLACEMENT: 10.0,
MIN_COST: 0,
MAX_COST: 999999.99,
} as const;

View File

@@ -0,0 +1,326 @@
import { prisma } from "./db.server";
import type { Customer } from "@prisma/client";
import type { CreateCustomerData, UpdateCustomerData, CustomerWithVehicles } from "~/types/database";
// Get all customers with search and pagination
export async function getCustomers(
searchQuery?: string,
page: number = 1,
limit: number = 10
): Promise<{
customers: CustomerWithVehicles[];
total: number;
totalPages: number;
}> {
const offset = (page - 1) * limit;
// Build where clause for search
const whereClause: any = {};
if (searchQuery) {
const searchLower = searchQuery.toLowerCase();
whereClause.OR = [
{ name: { contains: searchLower } },
{ phone: { contains: searchLower } },
{ email: { contains: searchLower } },
{ address: { contains: searchLower } },
];
}
const [customers, total] = await Promise.all([
prisma.customer.findMany({
where: whereClause,
include: {
vehicles: {
select: {
id: true,
plateNumber: true,
manufacturer: true,
model: true,
year: true,
lastVisitDate: true,
suggestedNextVisitDate: true,
},
},
maintenanceVisits: {
select: {
id: true,
visitDate: true,
cost: true,
maintenanceJobs: true,
},
orderBy: { visitDate: 'desc' },
take: 5, // Only get last 5 visits for performance
},
},
orderBy: { createdDate: 'desc' },
skip: offset,
take: limit,
}),
prisma.customer.count({ where: whereClause }),
]);
return {
customers,
total,
totalPages: Math.ceil(total / limit),
};
}
// Get customer by ID with full relationships
export async function getCustomerById(id: number): Promise<CustomerWithVehicles | null> {
return await prisma.customer.findUnique({
where: { id },
include: {
vehicles: {
orderBy: { createdDate: 'desc' },
},
maintenanceVisits: {
include: {
vehicle: {
select: {
id: true,
plateNumber: true,
manufacturer: true,
model: true,
year: true,
},
},
},
orderBy: { visitDate: 'desc' },
take: 3, // Only get latest 3 visits for the enhanced view
},
},
});
}
// Create new customer
export async function createCustomer(
customerData: CreateCustomerData
): Promise<{ success: boolean; customer?: Customer; error?: string }> {
try {
// Check if customer with same phone or email already exists (if provided)
if (customerData.phone || customerData.email) {
const existingCustomer = await prisma.customer.findFirst({
where: {
OR: [
customerData.phone ? { phone: customerData.phone } : {},
customerData.email ? { email: customerData.email } : {},
].filter(condition => Object.keys(condition).length > 0),
},
});
if (existingCustomer) {
if (existingCustomer.phone === customerData.phone) {
return { success: false, error: "رقم الهاتف موجود بالفعل" };
}
if (existingCustomer.email === customerData.email) {
return { success: false, error: "البريد الإلكتروني موجود بالفعل" };
}
}
}
// Create customer
const customer = await prisma.customer.create({
data: {
name: customerData.name.trim(),
phone: customerData.phone?.trim() || null,
email: customerData.email?.trim() || null,
address: customerData.address?.trim() || null,
},
});
return { success: true, customer };
} catch (error) {
console.error("Error creating customer:", error);
return { success: false, error: "حدث خطأ أثناء إنشاء العميل" };
}
}
// Update customer
export async function updateCustomer(
id: number,
customerData: UpdateCustomerData
): Promise<{ success: boolean; customer?: Customer; error?: string }> {
try {
// Check if customer exists
const existingCustomer = await prisma.customer.findUnique({
where: { id },
});
if (!existingCustomer) {
return { success: false, error: "العميل غير موجود" };
}
// Check for phone/email conflicts with other customers
if (customerData.phone || customerData.email) {
const conflictCustomer = await prisma.customer.findFirst({
where: {
AND: [
{ id: { not: id } },
{
OR: [
customerData.phone ? { phone: customerData.phone } : {},
customerData.email ? { email: customerData.email } : {},
].filter(condition => Object.keys(condition).length > 0),
},
],
},
});
if (conflictCustomer) {
if (conflictCustomer.phone === customerData.phone) {
return { success: false, error: "رقم الهاتف موجود بالفعل" };
}
if (conflictCustomer.email === customerData.email) {
return { success: false, error: "البريد الإلكتروني موجود بالفعل" };
}
}
}
// Prepare update data
const updateData: any = {};
if (customerData.name !== undefined) updateData.name = customerData.name.trim();
if (customerData.phone !== undefined) updateData.phone = customerData.phone?.trim() || null;
if (customerData.email !== undefined) updateData.email = customerData.email?.trim() || null;
if (customerData.address !== undefined) updateData.address = customerData.address?.trim() || null;
// Update customer
const customer = await prisma.customer.update({
where: { id },
data: updateData,
});
return { success: true, customer };
} catch (error) {
console.error("Error updating customer:", error);
return { success: false, error: "حدث خطأ أثناء تحديث العميل" };
}
}
// Delete customer with relationship handling
export async function deleteCustomer(
id: number
): Promise<{ success: boolean; error?: string }> {
try {
// Check if customer exists
const existingCustomer = await prisma.customer.findUnique({
where: { id },
include: {
vehicles: true,
maintenanceVisits: true,
},
});
if (!existingCustomer) {
return { success: false, error: "العميل غير موجود" };
}
// Check if customer has vehicles or maintenance visits
if (existingCustomer.vehicles.length > 0) {
return {
success: false,
error: `لا يمكن حذف العميل لأنه يملك ${existingCustomer.vehicles.length} مركبة. يرجى حذف المركبات أولاً`
};
}
if (existingCustomer.maintenanceVisits.length > 0) {
return {
success: false,
error: `لا يمكن حذف العميل لأنه لديه ${existingCustomer.maintenanceVisits.length} زيارة صيانة. يرجى حذف الزيارات أولاً`
};
}
// Delete customer
await prisma.customer.delete({
where: { id },
});
return { success: true };
} catch (error) {
console.error("Error deleting customer:", error);
return { success: false, error: "حدث خطأ أثناء حذف العميل" };
}
}
// Get customers for dropdown/select options
export async function getCustomersForSelect(): Promise<{ id: number; name: string; phone?: string | null }[]> {
return await prisma.customer.findMany({
select: {
id: true,
name: true,
phone: true,
},
orderBy: { name: 'asc' },
});
}
// Get customer statistics
export async function getCustomerStats(customerId: number): Promise<{
totalVehicles: number;
totalVisits: number;
totalSpent: number;
lastVisitDate?: Date;
} | null> {
const customer = await prisma.customer.findUnique({
where: { id: customerId },
include: {
vehicles: {
select: { id: true },
},
maintenanceVisits: {
select: {
cost: true,
visitDate: true,
},
orderBy: { visitDate: 'desc' },
},
},
});
if (!customer) return null;
const totalSpent = customer.maintenanceVisits.reduce((sum, visit) => sum + visit.cost, 0);
const lastVisitDate = customer.maintenanceVisits.length > 0
? customer.maintenanceVisits[0].visitDate
: undefined;
return {
totalVehicles: customer.vehicles.length,
totalVisits: customer.maintenanceVisits.length,
totalSpent,
lastVisitDate,
};
}
// Search customers by name or phone (for autocomplete)
export async function searchCustomers(query: string, limit: number = 10): Promise<{
id: number;
name: string;
phone?: string | null;
email?: string | null;
}[]> {
if (!query || query.trim().length < 2) {
return [];
}
const searchLower = query.toLowerCase();
return await prisma.customer.findMany({
where: {
OR: [
{ name: { contains: searchLower } },
{ phone: { contains: searchLower } },
{ email: { contains: searchLower } },
],
},
select: {
id: true,
name: true,
phone: true,
email: true,
},
orderBy: { name: 'asc' },
take: limit,
});
}

405
app/lib/db.server.ts Normal file
View File

@@ -0,0 +1,405 @@
import { PrismaClient } from '@prisma/client';
let prisma: PrismaClient;
declare global {
var __db__: PrismaClient;
}
// This is needed because in development we don't want to restart
// the server with every change, but we want to make sure we don't
// create a new connection to the DB with every change either.
// In production, we'll have a single connection to the DB.
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient();
} else {
if (!global.__db__) {
global.__db__ = new PrismaClient();
}
prisma = global.__db__;
prisma.$connect();
}
export { prisma };
// Database utility functions
export async function createUser(data: {
name: string;
username: string;
email: string;
password: string;
authLevel: number;
status?: string;
}) {
return prisma.user.create({
data: {
...data,
status: data.status || 'active',
},
});
}
export async function getUserByUsername(username: string) {
return prisma.user.findUnique({
where: { username },
});
}
export async function getUserByEmail(email: string) {
return prisma.user.findUnique({
where: { email },
});
}
export async function getUserById(id: number) {
return prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
username: true,
email: true,
status: true,
authLevel: true,
createdDate: true,
editDate: true,
},
});
}
export async function updateUser(id: number, data: {
name?: string;
username?: string;
email?: string;
password?: string;
authLevel?: number;
status?: string;
}) {
return prisma.user.update({
where: { id },
data,
});
}
export async function deleteUser(id: number) {
return prisma.user.delete({
where: { id },
});
}
export async function getUsers(authLevel?: number) {
const where = authLevel ? { authLevel: { gte: authLevel } } : {};
return prisma.user.findMany({
where,
select: {
id: true,
name: true,
username: true,
email: true,
status: true,
authLevel: true,
createdDate: true,
editDate: true,
},
orderBy: { createdDate: 'desc' },
});
}
// Customer operations
export async function createCustomer(data: {
name: string;
phone?: string;
email?: string;
address?: string;
}) {
return prisma.customer.create({ data });
}
export async function getCustomers() {
return prisma.customer.findMany({
include: {
vehicles: true,
_count: {
select: {
vehicles: true,
maintenanceVisits: true,
},
},
},
orderBy: { createdDate: 'desc' },
});
}
export async function getCustomerById(id: number) {
return prisma.customer.findUnique({
where: { id },
include: {
vehicles: true,
maintenanceVisits: {
include: {
vehicle: true,
},
orderBy: { visitDate: 'desc' },
},
},
});
}
export async function updateCustomer(id: number, data: {
name?: string;
phone?: string;
email?: string;
address?: string;
}) {
return prisma.customer.update({
where: { id },
data,
});
}
export async function deleteCustomer(id: number) {
return prisma.customer.delete({
where: { id },
});
}
// Vehicle operations
export async function createVehicle(data: {
plateNumber: string;
bodyType: string;
manufacturer: string;
model: string;
trim?: string;
year: number;
transmission: string;
fuel: string;
cylinders?: number;
engineDisplacement?: number;
useType: string;
ownerId: number;
}) {
return prisma.vehicle.create({ data });
}
export async function getVehicles() {
return prisma.vehicle.findMany({
include: {
owner: true,
_count: {
select: {
maintenanceVisits: true,
},
},
},
orderBy: { createdDate: 'desc' },
});
}
export async function getVehicleById(id: number) {
return prisma.vehicle.findUnique({
where: { id },
include: {
owner: true,
maintenanceVisits: {
include: {
income: true,
},
orderBy: { visitDate: 'desc' },
},
},
});
}
export async function updateVehicle(id: number, data: {
plateNumber?: string;
bodyType?: string;
manufacturer?: string;
model?: string;
trim?: string;
year?: number;
transmission?: string;
fuel?: string;
cylinders?: number;
engineDisplacement?: number;
useType?: string;
ownerId?: number;
lastVisitDate?: Date;
suggestedNextVisitDate?: Date;
}) {
return prisma.vehicle.update({
where: { id },
data,
});
}
export async function deleteVehicle(id: number) {
return prisma.vehicle.delete({
where: { id },
});
}
// Maintenance visit operations
export async function createMaintenanceVisit(data: {
vehicleId: number;
customerId: number;
maintenanceType: string;
description: string;
cost: number;
paymentStatus?: string;
kilometers: number;
visitDate?: Date;
nextVisitDelay: number;
}) {
return prisma.$transaction(async (tx) => {
// Create the maintenance visit
const visit = await tx.maintenanceVisit.create({
data: {
...data,
paymentStatus: data.paymentStatus || 'pending',
visitDate: data.visitDate || new Date(),
},
});
// Update vehicle's last visit date and calculate next visit date
const nextVisitDate = new Date();
nextVisitDate.setMonth(nextVisitDate.getMonth() + data.nextVisitDelay);
await tx.vehicle.update({
where: { id: data.vehicleId },
data: {
lastVisitDate: visit.visitDate,
suggestedNextVisitDate: nextVisitDate,
},
});
// Create corresponding income record
await tx.income.create({
data: {
maintenanceVisitId: visit.id,
amount: data.cost,
incomeDate: visit.visitDate,
},
});
return visit;
});
}
export async function getMaintenanceVisits() {
return prisma.maintenanceVisit.findMany({
include: {
vehicle: {
include: {
owner: true,
},
},
customer: true,
income: true,
},
orderBy: { visitDate: 'desc' },
});
}
export async function getMaintenanceVisitById(id: number) {
return prisma.maintenanceVisit.findUnique({
where: { id },
include: {
vehicle: {
include: {
owner: true,
},
},
customer: true,
income: true,
},
});
}
export async function updateMaintenanceVisit(id: number, data: {
vehicleId?: number;
customerId?: number;
maintenanceType?: string;
description?: string;
cost?: number;
paymentStatus?: string;
kilometers?: number;
visitDate?: Date;
nextVisitDelay?: number;
}) {
return prisma.maintenanceVisit.update({
where: { id },
data,
});
}
export async function deleteMaintenanceVisit(id: number) {
return prisma.maintenanceVisit.delete({
where: { id },
});
}
// Financial operations
export async function createExpense(data: {
description: string;
category: string;
amount: number;
expenseDate?: Date;
}) {
return prisma.expense.create({
data: {
...data,
expenseDate: data.expenseDate || new Date(),
},
});
}
export async function getExpenses() {
return prisma.expense.findMany({
orderBy: { expenseDate: 'desc' },
});
}
export async function getIncome() {
return prisma.income.findMany({
include: {
maintenanceVisit: {
include: {
vehicle: true,
customer: true,
},
},
},
orderBy: { incomeDate: 'desc' },
});
}
export async function getFinancialSummary(dateFrom?: Date, dateTo?: Date) {
const where = dateFrom && dateTo ? {
AND: [
{ createdDate: { gte: dateFrom } },
{ createdDate: { lte: dateTo } },
],
} : {};
const [totalIncome, totalExpenses] = await Promise.all([
prisma.income.aggregate({
where: dateFrom && dateTo ? {
incomeDate: { gte: dateFrom, lte: dateTo },
} : {},
_sum: { amount: true },
}),
prisma.expense.aggregate({
where: dateFrom && dateTo ? {
expenseDate: { gte: dateFrom, lte: dateTo },
} : {},
_sum: { amount: true },
}),
]);
return {
totalIncome: totalIncome._sum.amount || 0,
totalExpenses: totalExpenses._sum.amount || 0,
netProfit: (totalIncome._sum.amount || 0) - (totalExpenses._sum.amount || 0),
};
}

View File

@@ -0,0 +1,176 @@
import { prisma } from "./db.server";
import type { Expense } from "@prisma/client";
import type { CreateExpenseData, UpdateExpenseData, FinancialSearchParams } from "~/types/database";
// Get all expenses with search and pagination
export async function getExpenses(
searchQuery?: string,
page: number = 1,
limit: number = 10,
category?: string,
dateFrom?: Date,
dateTo?: Date
): Promise<{
expenses: Expense[];
total: number;
totalPages: number;
}> {
const offset = (page - 1) * limit;
// Build where clause for search and filters
const whereClause: any = {};
if (category) {
whereClause.category = category;
}
if (dateFrom || dateTo) {
whereClause.expenseDate = {};
if (dateFrom) {
whereClause.expenseDate.gte = dateFrom;
}
if (dateTo) {
whereClause.expenseDate.lte = dateTo;
}
}
if (searchQuery) {
const searchLower = searchQuery.toLowerCase();
whereClause.OR = [
{ description: { contains: searchLower } },
{ category: { contains: searchLower } },
];
}
const [expenses, total] = await Promise.all([
prisma.expense.findMany({
where: whereClause,
orderBy: { expenseDate: 'desc' },
skip: offset,
take: limit,
}),
prisma.expense.count({ where: whereClause }),
]);
const totalPages = Math.ceil(total / limit);
return {
expenses,
total,
totalPages,
};
}
// Get a single expense by ID
export async function getExpenseById(id: number): Promise<Expense | null> {
return prisma.expense.findUnique({
where: { id },
});
}
// Create a new expense
export async function createExpense(data: CreateExpenseData): Promise<Expense> {
return prisma.expense.create({
data: {
description: data.description,
category: data.category,
amount: data.amount,
expenseDate: data.expenseDate || new Date(),
},
});
}
// Update an existing expense
export async function updateExpense(id: number, data: UpdateExpenseData): Promise<Expense> {
return prisma.expense.update({
where: { id },
data: {
description: data.description,
category: data.category,
amount: data.amount,
expenseDate: data.expenseDate,
},
});
}
// Delete an expense
export async function deleteExpense(id: number): Promise<void> {
await prisma.expense.delete({
where: { id },
});
}
// Get expense categories for dropdown
export async function getExpenseCategories(): Promise<string[]> {
const categories = await prisma.expense.findMany({
select: { category: true },
distinct: ['category'],
orderBy: { category: 'asc' },
});
return categories.map(c => c.category);
}
// Get expenses by category for reporting
export async function getExpensesByCategory(
dateFrom?: Date,
dateTo?: Date
): Promise<{ category: string; total: number; count: number }[]> {
const whereClause: any = {};
if (dateFrom || dateTo) {
whereClause.expenseDate = {};
if (dateFrom) {
whereClause.expenseDate.gte = dateFrom;
}
if (dateTo) {
whereClause.expenseDate.lte = dateTo;
}
}
const result = await prisma.expense.groupBy({
by: ['category'],
where: whereClause,
_sum: {
amount: true,
},
_count: {
id: true,
},
orderBy: {
_sum: {
amount: 'desc',
},
},
});
return result.map(item => ({
category: item.category,
total: item._sum.amount || 0,
count: item._count.id,
}));
}
// Get total expenses for a date range
export async function getTotalExpenses(dateFrom?: Date, dateTo?: Date): Promise<number> {
const whereClause: any = {};
if (dateFrom || dateTo) {
whereClause.expenseDate = {};
if (dateFrom) {
whereClause.expenseDate.gte = dateFrom;
}
if (dateTo) {
whereClause.expenseDate.lte = dateTo;
}
}
const result = await prisma.expense.aggregate({
where: whereClause,
_sum: {
amount: true,
},
});
return result._sum.amount || 0;
}

View File

@@ -0,0 +1,361 @@
import { prisma } from "./db.server";
import { getTotalExpenses, getExpensesByCategory } from "./expense-management.server";
// Financial summary interface
export interface FinancialSummary {
totalIncome: number;
totalExpenses: number;
netProfit: number;
incomeCount: number;
expenseCount: number;
profitMargin: number;
}
// Monthly financial data interface
export interface MonthlyFinancialData {
month: string;
year: number;
income: number;
expenses: number;
profit: number;
}
// Category breakdown interface
export interface CategoryBreakdown {
category: string;
amount: number;
count: number;
percentage: number;
}
// Get financial summary for a date range
export async function getFinancialSummary(
dateFrom?: Date,
dateTo?: Date
): Promise<FinancialSummary> {
const whereClause: any = {};
if (dateFrom || dateTo) {
whereClause.incomeDate = {};
if (dateFrom) {
whereClause.incomeDate.gte = dateFrom;
}
if (dateTo) {
whereClause.incomeDate.lte = dateTo;
}
}
// Get income data
const incomeResult = await prisma.income.aggregate({
where: whereClause,
_sum: {
amount: true,
},
_count: {
id: true,
},
});
const totalIncome = incomeResult._sum.amount || 0;
const incomeCount = incomeResult._count.id;
// Get expense data
const totalExpenses = await getTotalExpenses(dateFrom, dateTo);
const expenseCount = await prisma.expense.count({
where: dateFrom || dateTo ? {
expenseDate: {
...(dateFrom && { gte: dateFrom }),
...(dateTo && { lte: dateTo }),
},
} : undefined,
});
// Calculate derived metrics
const netProfit = totalIncome - totalExpenses;
const profitMargin = totalIncome > 0 ? (netProfit / totalIncome) * 100 : 0;
return {
totalIncome,
totalExpenses,
netProfit,
incomeCount,
expenseCount,
profitMargin,
};
}
// Get monthly financial data for the last 12 months
export async function getMonthlyFinancialData(): Promise<MonthlyFinancialData[]> {
const months: MonthlyFinancialData[] = [];
const currentDate = new Date();
for (let i = 11; i >= 0; i--) {
const date = new Date(currentDate.getFullYear(), currentDate.getMonth() - i, 1);
const nextMonth = new Date(date.getFullYear(), date.getMonth() + 1, 1);
const monthNumber = date.getMonth() + 1; // 1-12
const year = date.getFullYear();
// Get income for this month
const incomeResult = await prisma.income.aggregate({
where: {
incomeDate: {
gte: date,
lt: nextMonth,
},
},
_sum: {
amount: true,
},
});
// Get expenses for this month
const expenseResult = await prisma.expense.aggregate({
where: {
expenseDate: {
gte: date,
lt: nextMonth,
},
},
_sum: {
amount: true,
},
});
const income = incomeResult._sum.amount || 0;
const expenses = expenseResult._sum.amount || 0;
const profit = income - expenses;
months.push({
month: monthNumber.toString(),
year,
income,
expenses,
profit,
});
}
return months;
}
// Get income breakdown by maintenance type
export async function getIncomeByMaintenanceType(
dateFrom?: Date,
dateTo?: Date
): Promise<CategoryBreakdown[]> {
const whereClause: any = {};
if (dateFrom || dateTo) {
whereClause.incomeDate = {};
if (dateFrom) {
whereClause.incomeDate.gte = dateFrom;
}
if (dateTo) {
whereClause.incomeDate.lte = dateTo;
}
}
// Get income grouped by maintenance type
const result = await prisma.income.findMany({
where: whereClause,
include: {
maintenanceVisit: {
select: {
maintenanceJobs: true,
},
},
},
});
// Group by maintenance type
const grouped = result.reduce((acc, income) => {
try {
const jobs = JSON.parse(income.maintenanceVisit.maintenanceJobs);
// Calculate total cost from individual jobs to determine weights
const totalJobCost = jobs.reduce((sum: number, job: any) => sum + (job.cost || 0), 0);
if (totalJobCost > 0) {
// Weighted distribution based on specific costs
jobs.forEach((job: any) => {
const type = job.job || 'غير محدد';
const jobCost = job.cost || 0;
// Calculate weight: if total cost is 0 (shouldn't happen due to if check), avoid division by zero
const weight = jobCost / totalJobCost;
const attributedAmount = income.amount * weight;
if (!acc[type]) {
acc[type] = { amount: 0, count: 0 };
}
acc[type].amount += attributedAmount;
acc[type].count += 1;
});
} else {
// Fallback: Equal distribution (Legacy behavior)
const types = jobs.map((job: any) => job.job || 'غير محدد');
types.forEach((type: string) => {
if (!acc[type]) {
acc[type] = { amount: 0, count: 0 };
}
acc[type].amount += income.amount / types.length;
acc[type].count += 1;
});
}
} catch {
// Fallback for invalid JSON
const type = 'غير محدد';
if (!acc[type]) {
acc[type] = { amount: 0, count: 0 };
}
acc[type].amount += income.amount;
acc[type].count += 1;
}
return acc;
}, {} as Record<string, { amount: number; count: number }>);
// Calculate total for percentage calculation
const total = Object.values(grouped).reduce((sum, item) => sum + item.amount, 0);
// Convert to array with percentages
return Object.entries(grouped)
.map(([category, data]) => ({
category,
amount: data.amount,
count: data.count,
percentage: total > 0 ? (data.amount / total) * 100 : 0,
}))
.sort((a, b) => b.amount - a.amount);
}
// Get expense breakdown by category
export async function getExpenseBreakdown(
dateFrom?: Date,
dateTo?: Date
): Promise<CategoryBreakdown[]> {
const categoryData = await getExpensesByCategory(dateFrom, dateTo);
const total = categoryData.reduce((sum, item) => sum + item.total, 0);
return categoryData.map(item => ({
category: item.category,
amount: item.total,
count: item.count,
percentage: total > 0 ? (item.total / total) * 100 : 0,
}));
}
// Get top customers by revenue
export async function getTopCustomersByRevenue(
limit: number = 10,
dateFrom?: Date,
dateTo?: Date
): Promise<{
customerId: number;
customerName: string;
totalRevenue: number;
visitCount: number;
}[]> {
const whereClause: any = {};
if (dateFrom || dateTo) {
whereClause.incomeDate = {};
if (dateFrom) {
whereClause.incomeDate.gte = dateFrom;
}
if (dateTo) {
whereClause.incomeDate.lte = dateTo;
}
}
const result = await prisma.income.findMany({
where: whereClause,
include: {
maintenanceVisit: {
include: {
customer: {
select: {
id: true,
name: true,
},
},
},
},
},
});
// Group by customer
const customerRevenue = result.reduce((acc, income) => {
const customer = income.maintenanceVisit.customer;
const customerId = customer.id;
if (!acc[customerId]) {
acc[customerId] = {
customerId,
customerName: customer.name,
totalRevenue: 0,
visitCount: 0,
};
}
acc[customerId].totalRevenue += income.amount;
acc[customerId].visitCount += 1;
return acc;
}, {} as Record<number, {
customerId: number;
customerName: string;
totalRevenue: number;
visitCount: number;
}>);
return Object.values(customerRevenue)
.sort((a, b) => b.totalRevenue - a.totalRevenue)
.slice(0, limit);
}
// Get financial trends (comparing current period with previous period)
export async function getFinancialTrends(
dateFrom: Date,
dateTo: Date
): Promise<{
currentPeriod: FinancialSummary;
previousPeriod: FinancialSummary;
trends: {
incomeGrowth: number;
expenseGrowth: number;
profitGrowth: number;
};
}> {
// Calculate previous period dates
const periodLength = dateTo.getTime() - dateFrom.getTime();
const previousDateTo = new Date(dateFrom.getTime() - 1);
const previousDateFrom = new Date(previousDateTo.getTime() - periodLength);
// Get current and previous period summaries
const [currentPeriod, previousPeriod] = await Promise.all([
getFinancialSummary(dateFrom, dateTo),
getFinancialSummary(previousDateFrom, previousDateTo),
]);
// Calculate growth percentages
const incomeGrowth = previousPeriod.totalIncome > 0
? ((currentPeriod.totalIncome - previousPeriod.totalIncome) / previousPeriod.totalIncome) * 100
: 0;
const expenseGrowth = previousPeriod.totalExpenses > 0
? ((currentPeriod.totalExpenses - previousPeriod.totalExpenses) / previousPeriod.totalExpenses) * 100
: 0;
const profitGrowth = previousPeriod.netProfit !== 0
? ((currentPeriod.netProfit - previousPeriod.netProfit) / Math.abs(previousPeriod.netProfit)) * 100
: 0;
return {
currentPeriod,
previousPeriod,
trends: {
incomeGrowth,
expenseGrowth,
profitGrowth,
},
};
}

274
app/lib/form-validation.ts Normal file
View File

@@ -0,0 +1,274 @@
import { z } from 'zod';
import { VALIDATION, AUTH_LEVELS, USER_STATUS, TRANSMISSION_TYPES, FUEL_TYPES, USE_TYPES, PAYMENT_STATUS } from './constants';
// Zod schemas for server-side validation
export const userSchema = z.object({
name: z.string()
.min(1, 'الاسم مطلوب')
.max(VALIDATION.MAX_NAME_LENGTH, `الاسم يجب أن يكون أقل من ${VALIDATION.MAX_NAME_LENGTH} حرف`)
.trim(),
username: z.string()
.min(3, 'اسم المستخدم يجب أن يكون على الأقل 3 أحرف')
.regex(/^[a-zA-Z0-9_]+$/, 'اسم المستخدم يجب أن يحتوي على أحرف وأرقام فقط')
.trim(),
email: z.string()
.email('البريد الإلكتروني غير صحيح')
.trim(),
password: z.string()
.min(VALIDATION.MIN_PASSWORD_LENGTH, `كلمة المرور يجب أن تكون على الأقل ${VALIDATION.MIN_PASSWORD_LENGTH} أحرف`),
authLevel: z.number()
.refine(val => Object.values(AUTH_LEVELS).includes(val as any), 'مستوى الصلاحية غير صحيح'),
status: z.enum([USER_STATUS.ACTIVE, USER_STATUS.INACTIVE], {
errorMap: () => ({ message: 'حالة المستخدم غير صحيحة' })
}),
});
export const customerSchema = z.object({
name: z.string()
.min(1, 'اسم العميل مطلوب')
.max(VALIDATION.MAX_NAME_LENGTH, `الاسم يجب أن يكون أقل من ${VALIDATION.MAX_NAME_LENGTH} حرف`)
.trim(),
phone: z.string()
.regex(/^[\+]?[0-9\s\-\(\)]*$/, 'رقم الهاتف غير صحيح')
.optional()
.or(z.literal('')),
email: z.string()
.email('البريد الإلكتروني غير صحيح')
.optional()
.or(z.literal('')),
address: z.string()
.optional()
.or(z.literal('')),
});
export const vehicleSchema = z.object({
plateNumber: z.string()
.min(1, 'رقم اللوحة مطلوب')
.trim(),
bodyType: z.string()
.min(1, 'نوع الهيكل مطلوب')
.trim(),
manufacturer: z.string()
.min(1, 'الشركة المصنعة مطلوبة')
.trim(),
model: z.string()
.min(1, 'الموديل مطلوب')
.trim(),
trim: z.string()
.optional()
.or(z.literal('')),
year: z.number()
.min(VALIDATION.MIN_YEAR, `السنة يجب أن تكون بين ${VALIDATION.MIN_YEAR} و ${VALIDATION.MAX_YEAR}`)
.max(VALIDATION.MAX_YEAR, `السنة يجب أن تكون بين ${VALIDATION.MIN_YEAR} و ${VALIDATION.MAX_YEAR}`),
transmission: z.enum(TRANSMISSION_TYPES.map(t => t.value) as [string, ...string[]], {
errorMap: () => ({ message: 'نوع ناقل الحركة غير صحيح' })
}),
fuel: z.enum(FUEL_TYPES.map(f => f.value) as [string, ...string[]], {
errorMap: () => ({ message: 'نوع الوقود غير صحيح' })
}),
cylinders: z.number()
.min(1, `عدد الأسطوانات يجب أن يكون بين 1 و ${VALIDATION.MAX_CYLINDERS}`)
.max(VALIDATION.MAX_CYLINDERS, `عدد الأسطوانات يجب أن يكون بين 1 و ${VALIDATION.MAX_CYLINDERS}`)
.optional()
.nullable(),
engineDisplacement: z.number()
.min(0.1, `سعة المحرك يجب أن تكون بين 0.1 و ${VALIDATION.MAX_ENGINE_DISPLACEMENT}`)
.max(VALIDATION.MAX_ENGINE_DISPLACEMENT, `سعة المحرك يجب أن تكون بين 0.1 و ${VALIDATION.MAX_ENGINE_DISPLACEMENT}`)
.optional()
.nullable(),
useType: z.enum(USE_TYPES.map(u => u.value) as [string, ...string[]], {
errorMap: () => ({ message: 'نوع الاستخدام غير صحيح' })
}),
ownerId: z.number()
.min(1, 'مالك المركبة مطلوب'),
});
export const maintenanceVisitSchema = z.object({
vehicleId: z.number()
.min(1, 'المركبة مطلوبة'),
customerId: z.number()
.min(1, 'العميل مطلوب'),
maintenanceType: z.string()
.min(1, 'نوع الصيانة مطلوب')
.trim(),
description: z.string()
.min(1, 'وصف الصيانة مطلوب')
.max(VALIDATION.MAX_DESCRIPTION_LENGTH, `الوصف يجب أن يكون أقل من ${VALIDATION.MAX_DESCRIPTION_LENGTH} حرف`)
.trim(),
cost: z.number()
.min(VALIDATION.MIN_COST, `التكلفة يجب أن تكون بين ${VALIDATION.MIN_COST} و ${VALIDATION.MAX_COST}`)
.max(VALIDATION.MAX_COST, `التكلفة يجب أن تكون بين ${VALIDATION.MIN_COST} و ${VALIDATION.MAX_COST}`),
paymentStatus: z.enum(Object.values(PAYMENT_STATUS) as [string, ...string[]], {
errorMap: () => ({ message: 'حالة الدفع غير صحيحة' })
}),
kilometers: z.number()
.min(0, 'عدد الكيلومترات يجب أن يكون رقم موجب'),
nextVisitDelay: z.number()
.refine(val => [1, 2, 3, 4].includes(val), 'فترة الزيارة التالية يجب أن تكون 1، 2، 3، أو 4 أشهر'),
});
export const expenseSchema = z.object({
description: z.string()
.min(1, 'وصف المصروف مطلوب')
.max(VALIDATION.MAX_DESCRIPTION_LENGTH, `الوصف يجب أن يكون أقل من ${VALIDATION.MAX_DESCRIPTION_LENGTH} حرف`)
.trim(),
category: z.string()
.min(1, 'فئة المصروف مطلوبة')
.trim(),
amount: z.number()
.min(VALIDATION.MIN_COST + 0.01, `المبلغ يجب أن يكون بين ${VALIDATION.MIN_COST + 0.01} و ${VALIDATION.MAX_COST}`)
.max(VALIDATION.MAX_COST, `المبلغ يجب أن يكون بين ${VALIDATION.MIN_COST + 0.01} و ${VALIDATION.MAX_COST}`),
});
// Validation result type
export interface ValidationResult {
success: boolean;
errors: Record<string, string>;
data?: any;
}
// Server-side validation functions
export function validateUserData(data: any): ValidationResult {
try {
const validatedData = userSchema.parse(data);
return { success: true, errors: {}, data: validatedData };
} catch (error) {
if (error instanceof z.ZodError) {
const errors: Record<string, string> = {};
error.issues.forEach((issue) => {
if (issue.path.length > 0) {
errors[issue.path[0] as string] = issue.message;
}
});
return { success: false, errors };
}
return { success: false, errors: { general: 'خطأ في التحقق من البيانات' } };
}
}
export function validateCustomerData(data: any): ValidationResult {
try {
const validatedData = customerSchema.parse(data);
return { success: true, errors: {}, data: validatedData };
} catch (error) {
if (error instanceof z.ZodError) {
const errors: Record<string, string> = {};
error.issues.forEach((issue) => {
if (issue.path.length > 0) {
errors[issue.path[0] as string] = issue.message;
}
});
return { success: false, errors };
}
return { success: false, errors: { general: 'خطأ في التحقق من البيانات' } };
}
}
export function validateVehicleData(data: any): ValidationResult {
try {
// Convert string numbers to actual numbers
const processedData = {
...data,
year: data.year ? parseInt(data.year) : undefined,
cylinders: data.cylinders ? parseInt(data.cylinders) : null,
engineDisplacement: data.engineDisplacement ? parseFloat(data.engineDisplacement) : null,
ownerId: data.ownerId ? parseInt(data.ownerId) : undefined,
};
const validatedData = vehicleSchema.parse(processedData);
return { success: true, errors: {}, data: validatedData };
} catch (error) {
if (error instanceof z.ZodError) {
const errors: Record<string, string> = {};
error.issues.forEach((issue) => {
if (issue.path.length > 0) {
errors[issue.path[0] as string] = issue.message;
}
});
return { success: false, errors };
}
return { success: false, errors: { general: 'خطأ في التحقق من البيانات' } };
}
}
export function validateMaintenanceVisitData(data: any): ValidationResult {
try {
// Convert string numbers to actual numbers
const processedData = {
...data,
vehicleId: data.vehicleId ? parseInt(data.vehicleId) : undefined,
customerId: data.customerId ? parseInt(data.customerId) : undefined,
cost: data.cost ? parseFloat(data.cost) : undefined,
kilometers: data.kilometers ? parseInt(data.kilometers) : undefined,
nextVisitDelay: data.nextVisitDelay ? parseInt(data.nextVisitDelay) : undefined,
};
const validatedData = maintenanceVisitSchema.parse(processedData);
return { success: true, errors: {}, data: validatedData };
} catch (error) {
if (error instanceof z.ZodError) {
const errors: Record<string, string> = {};
error.issues.forEach((issue) => {
if (issue.path.length > 0) {
errors[issue.path[0] as string] = issue.message;
}
});
return { success: false, errors };
}
return { success: false, errors: { general: 'خطأ في التحقق من البيانات' } };
}
}
export function validateExpenseData(data: any): ValidationResult {
try {
// Convert string numbers to actual numbers
const processedData = {
...data,
amount: data.amount ? parseFloat(data.amount) : undefined,
};
const validatedData = expenseSchema.parse(processedData);
return { success: true, errors: {}, data: validatedData };
} catch (error) {
if (error instanceof z.ZodError) {
const errors: Record<string, string> = {};
error.issues.forEach((issue) => {
if (issue.path.length > 0) {
errors[issue.path[0] as string] = issue.message;
}
});
return { success: false, errors };
}
return { success: false, errors: { general: 'خطأ في التحقق من البيانات' } };
}
}
// Client-side validation helpers
export function validateField(value: any, schema: z.ZodSchema, fieldName: string): string | null {
try {
schema.parse({ [fieldName]: value });
return null;
} catch (error) {
if (error instanceof z.ZodError) {
const fieldError = error.errors.find(err => err.path.includes(fieldName));
return fieldError?.message || null;
}
return null;
}
}
// Real-time validation for forms
export function validateFormField(fieldName: string, value: any, schema: z.ZodSchema): string | null {
try {
const fieldSchema = (schema as any).shape[fieldName];
if (fieldSchema) {
fieldSchema.parse(value);
}
return null;
} catch (error) {
if (error instanceof z.ZodError) {
return error.issues[0]?.message || null;
}
return null;
}
}

146
app/lib/layout-utils.ts Normal file
View File

@@ -0,0 +1,146 @@
/**
* Layout utilities for RTL support and responsive design
*/
export interface LayoutConfig {
direction: 'rtl' | 'ltr';
language: 'ar' | 'en';
sidebarCollapsed: boolean;
isMobile: boolean;
}
export const defaultLayoutConfig: LayoutConfig = {
direction: 'rtl',
language: 'ar',
sidebarCollapsed: false,
isMobile: false,
};
/**
* Get responsive classes based on screen size and RTL direction
*/
export function getResponsiveClasses(config: LayoutConfig) {
const { direction, sidebarCollapsed, isMobile } = config;
return {
container: `container-rtl ${direction === 'rtl' ? 'rtl' : 'ltr'}`,
flexRow: direction === 'rtl' ? 'flex-rtl-reverse' : 'flex-rtl',
textAlign: direction === 'rtl' ? 'text-right' : 'text-left',
marginStart: direction === 'rtl' ? 'mr-auto' : 'ml-auto',
marginEnd: direction === 'rtl' ? 'ml-auto' : 'mr-auto',
paddingStart: direction === 'rtl' ? 'pr-4' : 'pl-4',
paddingEnd: direction === 'rtl' ? 'pl-4' : 'pr-4',
borderStart: direction === 'rtl' ? 'border-r' : 'border-l',
borderEnd: direction === 'rtl' ? 'border-l' : 'border-r',
roundedStart: direction === 'rtl' ? 'rounded-r' : 'rounded-l',
roundedEnd: direction === 'rtl' ? 'rounded-l' : 'rounded-r',
sidebar: getSidebarClasses(config),
mainContent: getMainContentClasses(config),
};
}
function getSidebarClasses(config: LayoutConfig) {
const { direction, sidebarCollapsed, isMobile } = config;
let classes = 'sidebar-rtl';
if (isMobile) {
classes += ' mobile-sidebar-rtl';
if (sidebarCollapsed) {
classes += ' closed';
}
} else {
if (sidebarCollapsed) {
classes += ' collapsed';
}
}
return classes;
}
function getMainContentClasses(config: LayoutConfig) {
const { sidebarCollapsed, isMobile } = config;
let classes = 'main-content-rtl';
if (!isMobile && !sidebarCollapsed) {
classes += ' sidebar-open';
}
return classes;
}
/**
* Get Arabic text rendering classes
*/
export function getArabicTextClasses(size: 'sm' | 'base' | 'lg' | 'xl' | '2xl' = 'base') {
const sizeClasses = {
sm: 'text-sm',
base: 'text-base',
lg: 'text-lg',
xl: 'text-xl',
'2xl': 'text-2xl',
};
return `arabic-text font-arabic ${sizeClasses[size]}`;
}
/**
* Get form input classes with RTL support
*/
export function getFormInputClasses(error?: boolean) {
let classes = 'w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
if (error) {
classes += ' border-red-500 focus:ring-red-500 focus:border-red-500';
} else {
classes += ' border-gray-300';
}
return classes;
}
/**
* Get button classes with RTL support
*/
export function getButtonClasses(
variant: 'primary' | 'secondary' | 'danger' = 'primary',
size: 'sm' | 'md' | 'lg' = 'md'
) {
const baseClasses = 'btn inline-flex items-center justify-center font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2';
const variantClasses = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500',
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-900 focus:ring-gray-500',
danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500',
};
const sizeClasses = {
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
return `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]}`;
}
/**
* Breakpoint utilities for responsive design
*/
export const breakpoints = {
xs: 475,
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
'2xl': 1536,
};
/**
* Check if current screen size matches breakpoint
*/
export function useBreakpoint(breakpoint: keyof typeof breakpoints) {
if (typeof window === 'undefined') return false;
return window.innerWidth >= breakpoints[breakpoint];
}

View File

@@ -0,0 +1,220 @@
import { prisma } from "./db.server";
import type { MaintenanceType } from "@prisma/client";
import type { CreateMaintenanceTypeData, UpdateMaintenanceTypeData } from "~/types/database";
// Get all maintenance types
export async function getMaintenanceTypes(
includeInactive: boolean = false
): Promise<MaintenanceType[]> {
const whereClause = includeInactive ? {} : { isActive: true };
return await prisma.maintenanceType.findMany({
where: whereClause,
orderBy: { name: 'asc' },
});
}
// Get maintenance types for select dropdown
export async function getMaintenanceTypesForSelect(): Promise<{
id: number;
name: string;
}[]> {
return await prisma.maintenanceType.findMany({
where: { isActive: true },
select: {
id: true,
name: true,
},
orderBy: { name: 'asc' },
});
}
// Get maintenance type by ID
export async function getMaintenanceTypeById(id: number): Promise<MaintenanceType | null> {
return await prisma.maintenanceType.findUnique({
where: { id },
});
}
// Create new maintenance type
export async function createMaintenanceType(
data: CreateMaintenanceTypeData
): Promise<{ success: boolean; maintenanceType?: MaintenanceType; error?: string }> {
try {
// Check if maintenance type with same name already exists
const existingType = await prisma.maintenanceType.findUnique({
where: { name: data.name.trim() },
});
if (existingType) {
return { success: false, error: "نوع الصيانة موجود بالفعل" };
}
// Create maintenance type
const maintenanceType = await prisma.maintenanceType.create({
data: {
name: data.name.trim(),
description: data.description?.trim() || null,
isActive: data.isActive ?? true,
},
});
return { success: true, maintenanceType };
} catch (error) {
console.error("Error creating maintenance type:", error);
return { success: false, error: "حدث خطأ أثناء إنشاء نوع الصيانة" };
}
}
// Update maintenance type
export async function updateMaintenanceType(
id: number,
data: UpdateMaintenanceTypeData
): Promise<{ success: boolean; maintenanceType?: MaintenanceType; error?: string }> {
try {
// Check if maintenance type exists
const existingType = await prisma.maintenanceType.findUnique({
where: { id },
});
if (!existingType) {
return { success: false, error: "نوع الصيانة غير موجود" };
}
// Check for name conflicts with other types
if (data.name) {
const conflictType = await prisma.maintenanceType.findFirst({
where: {
AND: [
{ id: { not: id } },
{ name: data.name.trim() },
],
},
});
if (conflictType) {
return { success: false, error: "اسم نوع الصيانة موجود بالفعل" };
}
}
// Prepare update data
const updateData: any = {};
if (data.name !== undefined) updateData.name = data.name.trim();
if (data.description !== undefined) updateData.description = data.description?.trim() || null;
if (data.isActive !== undefined) updateData.isActive = data.isActive;
// Update maintenance type
const maintenanceType = await prisma.maintenanceType.update({
where: { id },
data: updateData,
});
return { success: true, maintenanceType };
} catch (error) {
console.error("Error updating maintenance type:", error);
return { success: false, error: "حدث خطأ أثناء تحديث نوع الصيانة" };
}
}
// Delete maintenance type (soft delete by setting isActive to false)
export async function deleteMaintenanceType(
id: number
): Promise<{ success: boolean; error?: string }> {
try {
// Check if maintenance type exists
const existingType = await prisma.maintenanceType.findUnique({
where: { id },
include: {
maintenanceVisits: true,
},
});
if (!existingType) {
return { success: false, error: "نوع الصيانة غير موجود" };
}
// Check if maintenance type has visits
if (existingType.maintenanceVisits.length > 0) {
// Soft delete - set as inactive instead of deleting
await prisma.maintenanceType.update({
where: { id },
data: { isActive: false },
});
return { success: true };
} else {
// Hard delete if no visits are associated
await prisma.maintenanceType.delete({
where: { id },
});
return { success: true };
}
} catch (error) {
console.error("Error deleting maintenance type:", error);
return { success: false, error: "حدث خطأ أثناء حذف نوع الصيانة" };
}
}
// Toggle maintenance type active status
export async function toggleMaintenanceTypeStatus(
id: number
): Promise<{ success: boolean; maintenanceType?: MaintenanceType; error?: string }> {
try {
const existingType = await prisma.maintenanceType.findUnique({
where: { id },
});
if (!existingType) {
return { success: false, error: "نوع الصيانة غير موجود" };
}
const maintenanceType = await prisma.maintenanceType.update({
where: { id },
data: { isActive: !existingType.isActive },
});
return { success: true, maintenanceType };
} catch (error) {
console.error("Error toggling maintenance type status:", error);
return { success: false, error: "حدث خطأ أثناء تغيير حالة نوع الصيانة" };
}
}
// Get maintenance type statistics
export async function getMaintenanceTypeStats(typeId: number): Promise<{
totalVisits: number;
totalRevenue: number;
averageCost: number;
lastVisitDate?: Date;
} | null> {
const type = await prisma.maintenanceType.findUnique({
where: { id: typeId },
include: {
maintenanceVisits: {
select: {
cost: true,
visitDate: true,
},
orderBy: { visitDate: 'desc' },
},
},
});
if (!type) return null;
const totalRevenue = type.maintenanceVisits.reduce((sum, visit) => sum + visit.cost, 0);
const averageCost = type.maintenanceVisits.length > 0
? totalRevenue / type.maintenanceVisits.length
: 0;
const lastVisitDate = type.maintenanceVisits.length > 0
? type.maintenanceVisits[0].visitDate
: undefined;
return {
totalVisits: type.maintenanceVisits.length,
totalRevenue,
averageCost,
lastVisitDate,
};
}

View File

@@ -0,0 +1,365 @@
import { prisma } from "./db.server";
import type { MaintenanceVisit } from "@prisma/client";
import type {
CreateMaintenanceVisitData,
UpdateMaintenanceVisitData,
MaintenanceVisitWithRelations
} from "~/types/database";
// Get all maintenance visits with search and pagination
export async function getMaintenanceVisits(
searchQuery?: string,
page: number = 1,
limit: number = 10,
vehicleId?: number,
customerId?: number
): Promise<{
visits: MaintenanceVisitWithRelations[];
total: number;
totalPages: number;
}> {
const offset = (page - 1) * limit;
// Build where clause for search and filters
const whereClause: any = {};
if (vehicleId) {
whereClause.vehicleId = vehicleId;
}
if (customerId) {
whereClause.customerId = customerId;
}
if (searchQuery) {
const searchLower = searchQuery.toLowerCase();
whereClause.OR = [
{ maintenanceJobs: { contains: searchLower } },
{ description: { contains: searchLower } },
{ paymentStatus: { contains: searchLower } },
{ vehicle: { plateNumber: { contains: searchLower } } },
{ customer: { name: { contains: searchLower } } },
];
}
const [visits, total] = await Promise.all([
prisma.maintenanceVisit.findMany({
where: whereClause,
include: {
vehicle: {
select: {
id: true,
plateNumber: true,
manufacturer: true,
model: true,
year: true,
},
},
customer: {
select: {
id: true,
name: true,
phone: true,
email: true,
},
},
income: {
select: {
id: true,
amount: true,
incomeDate: true,
},
},
},
orderBy: { visitDate: 'desc' },
skip: offset,
take: limit,
}),
prisma.maintenanceVisit.count({ where: whereClause }),
]);
const totalPages = Math.ceil(total / limit);
return {
visits,
total,
totalPages,
};
}
// Get a single maintenance visit by ID
export async function getMaintenanceVisitById(id: number): Promise<MaintenanceVisitWithRelations | null> {
return prisma.maintenanceVisit.findUnique({
where: { id },
include: {
vehicle: {
select: {
id: true,
plateNumber: true,
manufacturer: true,
model: true,
year: true,
ownerId: true,
},
},
customer: {
select: {
id: true,
name: true,
phone: true,
email: true,
},
},
income: {
select: {
id: true,
amount: true,
incomeDate: true,
},
},
},
});
}
// Create a new maintenance visit
export async function createMaintenanceVisit(data: CreateMaintenanceVisitData): Promise<MaintenanceVisit> {
// Calculate next visit date based on delay
const nextVisitDate = new Date();
nextVisitDate.setMonth(nextVisitDate.getMonth() + data.nextVisitDelay);
// Start a transaction to create visit, update vehicle, and create income
const result = await prisma.$transaction(async (tx) => {
// Create the maintenance visit
const visit = await tx.maintenanceVisit.create({
data: {
vehicleId: data.vehicleId,
customerId: data.customerId,
maintenanceJobs: JSON.stringify(data.maintenanceJobs),
description: data.description,
cost: data.cost,
paymentStatus: data.paymentStatus || "pending",
kilometers: data.kilometers,
visitDate: data.visitDate || new Date(),
nextVisitDelay: data.nextVisitDelay,
},
});
// Update vehicle's last visit date and suggested next visit date
await tx.vehicle.update({
where: { id: data.vehicleId },
data: {
lastVisitDate: visit.visitDate,
suggestedNextVisitDate: nextVisitDate,
},
});
// Create income record
await tx.income.create({
data: {
maintenanceVisitId: visit.id,
amount: data.cost,
incomeDate: visit.visitDate,
},
});
return visit;
});
return result;
}
// Update an existing maintenance visit
export async function updateMaintenanceVisit(
id: number,
data: UpdateMaintenanceVisitData
): Promise<MaintenanceVisit> {
// Calculate next visit date if delay is updated
let nextVisitDate: Date | undefined;
if (data.nextVisitDelay) {
nextVisitDate = new Date(data.visitDate || new Date());
nextVisitDate.setMonth(nextVisitDate.getMonth() + data.nextVisitDelay);
}
// Start a transaction to update visit, vehicle, and income
const result = await prisma.$transaction(async (tx) => {
// Get the current visit to check for changes
const currentVisit = await tx.maintenanceVisit.findUnique({
where: { id },
select: { vehicleId: true, cost: true, visitDate: true },
});
if (!currentVisit) {
throw new Error("Maintenance visit not found");
}
// Update the maintenance visit
const visit = await tx.maintenanceVisit.update({
where: { id },
data: {
maintenanceJobs: data.maintenanceJobs ? JSON.stringify(data.maintenanceJobs) : undefined,
description: data.description,
cost: data.cost,
paymentStatus: data.paymentStatus,
kilometers: data.kilometers,
visitDate: data.visitDate,
nextVisitDelay: data.nextVisitDelay,
},
});
// Update vehicle if visit date or delay changed
if (data.visitDate || data.nextVisitDelay) {
const updateData: any = {};
if (data.visitDate) {
updateData.lastVisitDate = data.visitDate;
}
if (nextVisitDate) {
updateData.suggestedNextVisitDate = nextVisitDate;
}
if (Object.keys(updateData).length > 0) {
await tx.vehicle.update({
where: { id: currentVisit.vehicleId },
data: updateData,
});
}
}
// Update income record if cost or date changed
if (data.cost !== undefined || data.visitDate) {
await tx.income.updateMany({
where: { maintenanceVisitId: id },
data: {
amount: data.cost || currentVisit.cost,
incomeDate: data.visitDate || currentVisit.visitDate,
},
});
}
return visit;
});
return result;
}
// Delete a maintenance visit
export async function deleteMaintenanceVisit(id: number): Promise<void> {
await prisma.$transaction(async (tx) => {
// Get visit details before deletion
const visit = await tx.maintenanceVisit.findUnique({
where: { id },
select: { vehicleId: true },
});
if (!visit) {
throw new Error("Maintenance visit not found");
}
// Delete the maintenance visit (income will be deleted by cascade)
await tx.maintenanceVisit.delete({
where: { id },
});
// Update vehicle's last visit date to the most recent remaining visit
const lastVisit = await tx.maintenanceVisit.findFirst({
where: { vehicleId: visit.vehicleId },
orderBy: { visitDate: 'desc' },
select: { visitDate: true, nextVisitDelay: true },
});
let updateData: any = {};
if (lastVisit) {
updateData.lastVisitDate = lastVisit.visitDate;
// Recalculate next visit date
const nextDate = new Date(lastVisit.visitDate);
nextDate.setMonth(nextDate.getMonth() + lastVisit.nextVisitDelay);
updateData.suggestedNextVisitDate = nextDate;
} else {
// No visits left, clear the dates
updateData.lastVisitDate = null;
updateData.suggestedNextVisitDate = null;
}
await tx.vehicle.update({
where: { id: visit.vehicleId },
data: updateData,
});
});
}
// Get maintenance visits for a specific vehicle
export async function getVehicleMaintenanceHistory(vehicleId: number): Promise<MaintenanceVisitWithRelations[]> {
return prisma.maintenanceVisit.findMany({
where: { vehicleId },
include: {
vehicle: {
select: {
id: true,
plateNumber: true,
manufacturer: true,
model: true,
year: true,
},
},
customer: {
select: {
id: true,
name: true,
phone: true,
email: true,
},
},
income: {
select: {
id: true,
amount: true,
incomeDate: true,
},
},
},
orderBy: { visitDate: 'desc' },
});
}
// Get maintenance visits for a specific customer
export async function getCustomerMaintenanceHistory(customerId: number): Promise<MaintenanceVisitWithRelations[]> {
return prisma.maintenanceVisit.findMany({
where: { customerId },
include: {
vehicle: {
select: {
id: true,
plateNumber: true,
manufacturer: true,
model: true,
year: true,
},
},
customer: {
select: {
id: true,
name: true,
phone: true,
email: true,
},
},
maintenanceType: {
select: {
id: true,
name: true,
description: true,
},
},
income: {
select: {
id: true,
amount: true,
incomeDate: true,
},
},
},
orderBy: { visitDate: 'desc' },
});
}

View File

@@ -0,0 +1,209 @@
import { prisma as db } from './db.server';
export interface AppSettings {
dateFormat: 'ar-SA' | 'en-US';
currency: string;
numberFormat: 'ar-SA' | 'en-US';
currencySymbol: string;
dateDisplayFormat: string;
}
export interface SettingItem {
id: number;
key: string;
value: string;
description?: string;
createdDate: Date;
updateDate: Date;
}
// Default settings
const DEFAULT_SETTINGS: AppSettings = {
dateFormat: 'ar-SA',
currency: 'JOD',
numberFormat: 'ar-SA',
currencySymbol: 'د.أ',
dateDisplayFormat: 'dd/MM/yyyy'
};
// Get all settings as a typed object
export async function getAppSettings(): Promise<AppSettings> {
try {
const settings = await db.settings.findMany();
const settingsMap = settings.reduce((acc, setting) => {
acc[setting.key] = setting.value;
return acc;
}, {} as Record<string, string>);
return {
dateFormat: (settingsMap.dateFormat as 'ar-SA' | 'en-US') || DEFAULT_SETTINGS.dateFormat,
currency: settingsMap.currency || DEFAULT_SETTINGS.currency,
numberFormat: (settingsMap.numberFormat as 'ar-SA' | 'en-US') || DEFAULT_SETTINGS.numberFormat,
currencySymbol: settingsMap.currencySymbol || DEFAULT_SETTINGS.currencySymbol,
dateDisplayFormat: settingsMap.dateDisplayFormat || DEFAULT_SETTINGS.dateDisplayFormat
};
} catch (error) {
console.error('Error fetching settings:', error);
return DEFAULT_SETTINGS;
}
}
// Get a specific setting by key
export async function getSetting(key: string): Promise<string | null> {
try {
const setting = await db.settings.findUnique({
where: { key }
});
return setting?.value || null;
} catch (error) {
console.error(`Error fetching setting ${key}:`, error);
return null;
}
}
// Update or create a setting
export async function updateSetting(key: string, value: string, description?: string): Promise<SettingItem> {
try {
return await db.settings.upsert({
where: { key },
update: {
value,
description: description || undefined,
updateDate: new Date()
},
create: {
key,
value,
description: description || undefined
}
});
} catch (error) {
console.error(`Error updating setting ${key}:`, error);
throw new Error(`Failed to update setting: ${key}`);
}
}
// Update multiple settings at once
export async function updateSettings(settings: Partial<AppSettings>): Promise<void> {
try {
const updates = Object.entries(settings).map(([key, value]) =>
updateSetting(key, value as string)
);
await Promise.all(updates);
} catch (error) {
console.error('Error updating settings:', error);
throw new Error('Failed to update settings');
}
}
// Get all settings for admin management
export async function getAllSettings(): Promise<SettingItem[]> {
try {
return await db.settings.findMany({
orderBy: { key: 'asc' }
});
} catch (error) {
console.error('Error fetching all settings:', error);
throw new Error('Failed to fetch settings');
}
}
// Initialize default settings if they don't exist
export async function initializeDefaultSettings(): Promise<void> {
try {
const existingSettings = await db.settings.findMany();
const existingKeys = new Set(existingSettings.map(s => s.key));
const defaultEntries = [
{ key: 'dateFormat', value: 'ar-SA', description: 'Date format locale (ar-SA or en-US)' },
{ key: 'currency', value: 'JOD', description: 'Currency code (JOD, USD, EUR, etc.)' },
{ key: 'numberFormat', value: 'ar-SA', description: 'Number format locale (ar-SA or en-US)' },
{ key: 'currencySymbol', value: 'د.أ', description: 'Currency symbol display' },
{ key: 'dateDisplayFormat', value: 'dd/MM/yyyy', description: 'Date display format pattern' }
];
const newSettings = defaultEntries.filter(entry => !existingKeys.has(entry.key));
if (newSettings.length > 0) {
await db.settings.createMany({
data: newSettings
});
}
} catch (error) {
console.error('Error initializing default settings:', error);
// Don't throw error, just log it and continue with defaults
}
}
// Formatting utilities using settings
export class SettingsFormatter {
constructor(private settings: AppSettings) {}
// Format number according to settings
formatNumber(value: number): string {
return value.toLocaleString(this.settings.numberFormat);
}
// Format currency according to settings
formatCurrency(value: number): string {
const formatted = value.toLocaleString(this.settings.numberFormat, {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
return `${formatted} ${this.settings.currencySymbol}`;
}
// Format date according to settings
formatDate(date: Date | string): string {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return this.formatDateWithPattern(dateObj, this.settings.dateDisplayFormat);
}
// Format datetime according to settings
formatDateTime(date: Date | string): string {
const dateObj = typeof date === 'string' ? new Date(date) : date;
const datePart = this.formatDateWithPattern(dateObj, this.settings.dateDisplayFormat);
const timePart = dateObj.toLocaleTimeString(this.settings.dateFormat, {
hour: '2-digit',
minute: '2-digit'
});
return `${datePart} ${timePart}`;
}
// Helper method to format date with custom pattern
private formatDateWithPattern(date: Date, pattern: string): string {
const day = date.getDate();
const month = date.getMonth() + 1;
const year = date.getFullYear();
// Format numbers according to locale
const formatNumber = (num: number, padLength: number = 2): string => {
const padded = num.toString().padStart(padLength, '0');
return this.settings.numberFormat === 'ar-SA'
? this.convertToArabicNumerals(padded)
: padded;
};
return pattern
.replace(/yyyy/g, formatNumber(year, 4))
.replace(/yy/g, formatNumber(year % 100, 2))
.replace(/MM/g, formatNumber(month, 2))
.replace(/M/g, formatNumber(month, 1))
.replace(/dd/g, formatNumber(day, 2))
.replace(/d/g, formatNumber(day, 1));
}
// Helper method to convert Western numerals to Arabic numerals
private convertToArabicNumerals(str: string): string {
const arabicNumerals = ['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩'];
return str.replace(/[0-9]/g, (digit) => arabicNumerals[parseInt(digit)]);
}
}
// Create formatter instance with current settings
export async function createFormatter(): Promise<SettingsFormatter> {
const settings = await getAppSettings();
return new SettingsFormatter(settings);
}

309
app/lib/table-utils.ts Normal file
View File

@@ -0,0 +1,309 @@
// Table utilities for searching, filtering, sorting, and pagination
export interface SortConfig {
key: string;
direction: 'asc' | 'desc';
}
export interface FilterConfig {
[key: string]: string | string[] | number | boolean;
}
export interface PaginationConfig {
page: number;
pageSize: number;
}
export interface TableState {
search: string;
filters: FilterConfig;
sort: SortConfig | null;
pagination: PaginationConfig;
}
// Search function with Arabic text support
export function searchData<T extends Record<string, any>>(
data: T[],
searchTerm: string,
searchableFields: (keyof T)[]
): T[] {
if (!searchTerm.trim()) return data;
const normalizedSearch = normalizeArabicText(searchTerm.toLowerCase());
return data.filter(item => {
return searchableFields.some(field => {
const value = item[field];
if (value == null) return false;
const normalizedValue = normalizeArabicText(String(value).toLowerCase());
return normalizedValue.includes(normalizedSearch);
});
});
}
// Filter data based on multiple criteria
export function filterData<T extends Record<string, any>>(
data: T[],
filters: FilterConfig
): T[] {
return data.filter(item => {
return Object.entries(filters).every(([key, filterValue]) => {
if (!filterValue || filterValue === '' || (Array.isArray(filterValue) && filterValue.length === 0)) {
return true;
}
const itemValue = item[key];
if (Array.isArray(filterValue)) {
// Multi-select filter
return filterValue.includes(String(itemValue));
}
if (typeof filterValue === 'string') {
// Text filter
if (itemValue == null) return false;
const normalizedItemValue = normalizeArabicText(String(itemValue).toLowerCase());
const normalizedFilterValue = normalizeArabicText(filterValue.toLowerCase());
return normalizedItemValue.includes(normalizedFilterValue);
}
if (typeof filterValue === 'number') {
// Numeric filter
return Number(itemValue) === filterValue;
}
if (typeof filterValue === 'boolean') {
// Boolean filter
return Boolean(itemValue) === filterValue;
}
return true;
});
});
}
// Sort data
export function sortData<T extends Record<string, any>>(
data: T[],
sortConfig: SortConfig | null
): T[] {
if (!sortConfig) return data;
return [...data].sort((a, b) => {
const aValue = a[sortConfig.key];
const bValue = b[sortConfig.key];
// Handle null/undefined values
if (aValue == null && bValue == null) return 0;
if (aValue == null) return sortConfig.direction === 'asc' ? 1 : -1;
if (bValue == null) return sortConfig.direction === 'asc' ? -1 : 1;
// Handle different data types
let comparison = 0;
if (typeof aValue === 'string' && typeof bValue === 'string') {
// String comparison with Arabic support
comparison = normalizeArabicText(aValue).localeCompare(normalizeArabicText(bValue), 'ar');
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
// Numeric comparison
comparison = aValue - bValue;
} else if (aValue instanceof Date && bValue instanceof Date) {
// Date comparison
comparison = aValue.getTime() - bValue.getTime();
} else {
// Fallback to string comparison
comparison = String(aValue).localeCompare(String(bValue), 'ar');
}
return sortConfig.direction === 'asc' ? comparison : -comparison;
});
}
// Paginate data
export function paginateData<T>(
data: T[],
pagination: PaginationConfig
): {
data: T[];
totalItems: number;
totalPages: number;
currentPage: number;
hasNextPage: boolean;
hasPreviousPage: boolean;
} {
const { page, pageSize } = pagination;
const totalItems = data.length;
const totalPages = Math.ceil(totalItems / pageSize);
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedData = data.slice(startIndex, endIndex);
return {
data: paginatedData,
totalItems,
totalPages,
currentPage: page,
hasNextPage: page < totalPages,
hasPreviousPage: page > 1,
};
}
// Process table data with all operations
export function processTableData<T extends Record<string, any>>(
data: T[],
state: TableState,
searchableFields: (keyof T)[]
) {
let processedData = [...data];
// Apply search
if (state.search) {
processedData = searchData(processedData, state.search, searchableFields);
}
// Apply filters
if (Object.keys(state.filters).length > 0) {
processedData = filterData(processedData, state.filters);
}
// Apply sorting
if (state.sort) {
processedData = sortData(processedData, state.sort);
}
// Apply pagination
const paginationResult = paginateData(processedData, state.pagination);
return {
...paginationResult,
filteredCount: processedData.length,
originalCount: data.length,
};
}
// Normalize Arabic text for better searching
function normalizeArabicText(text: string): string {
return text
// Normalize Arabic characters
.replace(/[أإآ]/g, 'ا')
.replace(/[ة]/g, 'ه')
.replace(/[ي]/g, 'ى')
// Remove diacritics
.replace(/[\u064B-\u065F\u0670\u06D6-\u06ED]/g, '')
// Normalize whitespace
.replace(/\s+/g, ' ')
.trim();
}
// Generate filter options from data
export function generateFilterOptions<T extends Record<string, any>>(
data: T[],
field: keyof T,
labelFormatter?: (value: any) => string
): { value: string; label: string }[] {
const uniqueValues = Array.from(new Set(
data
.map(item => item[field])
.filter(value => value != null && value !== '')
));
return uniqueValues
.sort((a, b) => {
if (typeof a === 'string' && typeof b === 'string') {
return normalizeArabicText(a).localeCompare(normalizeArabicText(b), 'ar');
}
return String(a).localeCompare(String(b));
})
.map(value => ({
value: String(value),
label: labelFormatter ? labelFormatter(value) : String(value),
}));
}
// Create table state from URL search params
export function createTableStateFromParams(
searchParams: URLSearchParams,
defaultPageSize = 10
): TableState {
return {
search: searchParams.get('search') || '',
filters: Object.fromEntries(
Array.from(searchParams.entries())
.filter(([key]) => key.startsWith('filter_'))
.map(([key, value]) => [key.replace('filter_', ''), value])
),
sort: searchParams.get('sort') && searchParams.get('sortDir') ? {
key: searchParams.get('sort')!,
direction: searchParams.get('sortDir') as 'asc' | 'desc',
} : null,
pagination: {
page: parseInt(searchParams.get('page') || '1'),
pageSize: parseInt(searchParams.get('pageSize') || String(defaultPageSize)),
},
};
}
// Convert table state to URL search params
export function tableStateToParams(state: TableState): URLSearchParams {
const params = new URLSearchParams();
if (state.search) {
params.set('search', state.search);
}
Object.entries(state.filters).forEach(([key, value]) => {
if (value && value !== '') {
params.set(`filter_${key}`, String(value));
}
});
if (state.sort) {
params.set('sort', state.sort.key);
params.set('sortDir', state.sort.direction);
}
if (state.pagination.page > 1) {
params.set('page', String(state.pagination.page));
}
if (state.pagination.pageSize !== 10) {
params.set('pageSize', String(state.pagination.pageSize));
}
return params;
}
// Debounce function for search input
export function debounce<T extends (...args: any[]) => any>(
func: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => func(...args), delay);
};
}
// Export utility types
export type TableColumn<T> = {
key: keyof T;
header: string;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;
render?: (value: any, item: T) => React.ReactNode;
width?: string;
align?: 'left' | 'center' | 'right';
};
export type TableAction<T> = {
label: string;
icon?: React.ReactNode;
onClick: (item: T) => void;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: (item: T) => boolean;
hidden?: (item: T) => boolean;
};

View File

@@ -0,0 +1,341 @@
import { prisma } from "./db.server";
import { hashPassword } from "./auth.server";
import type { User } from "@prisma/client";
import type { CreateUserData, UpdateUserData, UserWithoutPassword } from "~/types/database";
import { AUTH_LEVELS, USER_STATUS } from "~/types/auth";
// Get all users with role-based filtering
export async function getUsers(
currentUserAuthLevel: number,
searchQuery?: string,
page: number = 1,
limit: number = 10
): Promise<{
users: UserWithoutPassword[];
total: number;
totalPages: number;
}> {
const offset = (page - 1) * limit;
// Build where clause based on current user's auth level
const whereClause: any = {};
// Admins cannot see superadmin accounts
if (currentUserAuthLevel === AUTH_LEVELS.ADMIN) {
whereClause.authLevel = {
gt: AUTH_LEVELS.SUPERADMIN,
};
}
// Add search functionality
if (searchQuery) {
const searchLower = searchQuery.toLowerCase();
whereClause.OR = [
{ name: { contains: searchLower } },
{ username: { contains: searchLower } },
{ email: { contains: searchLower } },
];
}
const [users, total] = await Promise.all([
prisma.user.findMany({
where: whereClause,
select: {
id: true,
name: true,
username: true,
email: true,
status: true,
authLevel: true,
createdDate: true,
editDate: true,
},
orderBy: { createdDate: 'desc' },
skip: offset,
take: limit,
}),
prisma.user.count({ where: whereClause }),
]);
return {
users,
total,
totalPages: Math.ceil(total / limit),
};
}
// Get user by ID with role-based access control
export async function getUserById(
id: number,
currentUserAuthLevel: number
): Promise<UserWithoutPassword | null> {
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
username: true,
email: true,
status: true,
authLevel: true,
createdDate: true,
editDate: true,
},
});
if (!user) return null;
// Admins cannot access superadmin accounts
if (currentUserAuthLevel === AUTH_LEVELS.ADMIN && user.authLevel === AUTH_LEVELS.SUPERADMIN) {
return null;
}
return user;
}
// Create new user
export async function createUser(
userData: CreateUserData,
currentUserAuthLevel: number
): Promise<{ success: boolean; user?: UserWithoutPassword; error?: string }> {
try {
// Validate that current user can create users with the specified auth level
if (currentUserAuthLevel === AUTH_LEVELS.ADMIN && userData.authLevel === AUTH_LEVELS.SUPERADMIN) {
return { success: false, error: "لا يمكن للمدير إنشاء حساب مدير عام" };
}
// Check if username or email already exists
const existingUser = await prisma.user.findFirst({
where: {
OR: [
{ username: userData.username },
{ email: userData.email },
],
},
});
if (existingUser) {
if (existingUser.username === userData.username) {
return { success: false, error: "اسم المستخدم موجود بالفعل" };
}
if (existingUser.email === userData.email) {
return { success: false, error: "البريد الإلكتروني موجود بالفعل" };
}
}
// Hash password
const hashedPassword = await hashPassword(userData.password);
// Create user
const user = await prisma.user.create({
data: {
name: userData.name,
username: userData.username,
email: userData.email,
password: hashedPassword,
authLevel: userData.authLevel,
status: userData.status || USER_STATUS.ACTIVE,
},
select: {
id: true,
name: true,
username: true,
email: true,
status: true,
authLevel: true,
createdDate: true,
editDate: true,
},
});
return { success: true, user };
} catch (error) {
console.error("Error creating user:", error);
return { success: false, error: "حدث خطأ أثناء إنشاء المستخدم" };
}
}
// Update user
export async function updateUser(
id: number,
userData: UpdateUserData,
currentUserAuthLevel: number
): Promise<{ success: boolean; user?: UserWithoutPassword; error?: string }> {
try {
// Get existing user to check permissions
const existingUser = await getUserById(id, currentUserAuthLevel);
if (!existingUser) {
return { success: false, error: "المستخدم غير موجود أو لا يمكن الوصول إليه" };
}
// Validate auth level changes
if (userData.authLevel !== undefined) {
if (currentUserAuthLevel === AUTH_LEVELS.ADMIN && userData.authLevel === AUTH_LEVELS.SUPERADMIN) {
return { success: false, error: "لا يمكن للمدير تعيين مستوى مدير عام" };
}
}
// Check for username/email conflicts
if (userData.username || userData.email) {
const conflictUser = await prisma.user.findFirst({
where: {
AND: [
{ id: { not: id } },
{
OR: [
userData.username ? { username: userData.username } : {},
userData.email ? { email: userData.email } : {},
].filter(condition => Object.keys(condition).length > 0),
},
],
},
});
if (conflictUser) {
if (conflictUser.username === userData.username) {
return { success: false, error: "اسم المستخدم موجود بالفعل" };
}
if (conflictUser.email === userData.email) {
return { success: false, error: "البريد الإلكتروني موجود بالفعل" };
}
}
}
// Prepare update data
const updateData: any = {};
if (userData.name !== undefined) updateData.name = userData.name;
if (userData.username !== undefined) updateData.username = userData.username;
if (userData.email !== undefined) updateData.email = userData.email;
if (userData.authLevel !== undefined) updateData.authLevel = userData.authLevel;
if (userData.status !== undefined) updateData.status = userData.status;
// Hash new password if provided
if (userData.password) {
updateData.password = await hashPassword(userData.password);
}
// Update user
const user = await prisma.user.update({
where: { id },
data: updateData,
select: {
id: true,
name: true,
username: true,
email: true,
status: true,
authLevel: true,
createdDate: true,
editDate: true,
},
});
return { success: true, user };
} catch (error) {
console.error("Error updating user:", error);
return { success: false, error: "حدث خطأ أثناء تحديث المستخدم" };
}
}
// Delete user
export async function deleteUser(
id: number,
currentUserAuthLevel: number
): Promise<{ success: boolean; error?: string }> {
try {
// Get existing user to check permissions
const existingUser = await getUserById(id, currentUserAuthLevel);
if (!existingUser) {
return { success: false, error: "المستخدم غير موجود أو لا يمكن الوصول إليه" };
}
// Prevent deletion of superadmin by admin
if (currentUserAuthLevel === AUTH_LEVELS.ADMIN && existingUser.authLevel === AUTH_LEVELS.SUPERADMIN) {
return { success: false, error: "لا يمكن للمدير حذف حساب مدير عام" };
}
// Check if this is the last superadmin
if (existingUser.authLevel === AUTH_LEVELS.SUPERADMIN) {
const superadminCount = await prisma.user.count({
where: { authLevel: AUTH_LEVELS.SUPERADMIN },
});
if (superadminCount <= 1) {
return { success: false, error: "لا يمكن حذف آخر مدير عام في النظام" };
}
}
// Delete user
await prisma.user.delete({
where: { id },
});
return { success: true };
} catch (error) {
console.error("Error deleting user:", error);
return { success: false, error: "حدث خطأ أثناء حذف المستخدم" };
}
}
// Toggle user status (active/inactive)
export async function toggleUserStatus(
id: number,
currentUserAuthLevel: number
): Promise<{ success: boolean; user?: UserWithoutPassword; error?: string }> {
try {
const existingUser = await getUserById(id, currentUserAuthLevel);
if (!existingUser) {
return { success: false, error: "المستخدم غير موجود أو لا يمكن الوصول إليه" };
}
const newStatus = existingUser.status === USER_STATUS.ACTIVE
? USER_STATUS.INACTIVE
: USER_STATUS.ACTIVE;
const user = await prisma.user.update({
where: { id },
data: { status: newStatus },
select: {
id: true,
name: true,
username: true,
email: true,
status: true,
authLevel: true,
createdDate: true,
editDate: true,
},
});
return { success: true, user };
} catch (error) {
console.error("Error toggling user status:", error);
return { success: false, error: "حدث خطأ أثناء تغيير حالة المستخدم" };
}
}
// Get auth level display name
export function getAuthLevelName(authLevel: number): string {
switch (authLevel) {
case AUTH_LEVELS.SUPERADMIN:
return "مدير عام";
case AUTH_LEVELS.ADMIN:
return "مدير";
case AUTH_LEVELS.USER:
return "مستخدم";
default:
return "غير محدد";
}
}
// Get status display name
export function getStatusName(status: string): string {
switch (status) {
case USER_STATUS.ACTIVE:
return "نشط";
case USER_STATUS.INACTIVE:
return "غير نشط";
default:
return "غير محدد";
}
}

27
app/lib/user-utils.ts Normal file
View File

@@ -0,0 +1,27 @@
import { AUTH_LEVELS, USER_STATUS } from '~/types/auth';
// Get auth level display name (client-side version)
export function getAuthLevelName(authLevel: number): string {
switch (authLevel) {
case AUTH_LEVELS.SUPERADMIN:
return "مدير عام";
case AUTH_LEVELS.ADMIN:
return "مدير";
case AUTH_LEVELS.USER:
return "مستخدم";
default:
return "غير محدد";
}
}
// Get status display name (client-side version)
export function getStatusName(status: string): string {
switch (status) {
case USER_STATUS.ACTIVE:
return "نشط";
case USER_STATUS.INACTIVE:
return "غير نشط";
default:
return "غير محدد";
}
}

291
app/lib/validation-utils.ts Normal file
View File

@@ -0,0 +1,291 @@
// Validation utility functions with Arabic error messages
export interface ValidationRule {
required?: boolean;
minLength?: number;
maxLength?: number;
min?: number;
max?: number;
pattern?: RegExp;
email?: boolean;
phone?: boolean;
url?: boolean;
custom?: (value: any) => string | null;
}
export interface ValidationResult {
isValid: boolean;
error?: string;
}
// Arabic error messages
export const ERROR_MESSAGES = {
required: 'هذا الحقل مطلوب',
email: 'البريد الإلكتروني غير صحيح',
phone: 'رقم الهاتف غير صحيح',
url: 'الرابط غير صحيح',
minLength: (min: number) => `يجب أن يكون على الأقل ${min} أحرف`,
maxLength: (max: number) => `يجب أن يكون أقل من ${max} حرف`,
min: (min: number) => `يجب أن يكون على الأقل ${min}`,
max: (max: number) => `يجب أن يكون أقل من ${max}`,
pattern: 'التنسيق غير صحيح',
number: 'يجب أن يكون رقم صحيح',
integer: 'يجب أن يكون رقم صحيح',
positive: 'يجب أن يكون رقم موجب',
negative: 'يجب أن يكون رقم سالب',
date: 'التاريخ غير صحيح',
time: 'الوقت غير صحيح',
datetime: 'التاريخ والوقت غير صحيح',
};
// Regular expressions for validation
export const PATTERNS = {
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
phone: /^[\+]?[0-9\s\-\(\)]+$/,
url: /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/,
arabicText: /^[\u0600-\u06FF\s\d\p{P}]+$/u,
englishText: /^[a-zA-Z\s\d\p{P}]+$/u,
alphanumeric: /^[a-zA-Z0-9]+$/,
numeric: /^\d+$/,
decimal: /^\d+(\.\d+)?$/,
plateNumber: /^[A-Z0-9\u0600-\u06FF\s\-]+$/u,
username: /^[a-zA-Z0-9_]+$/,
};
// Validate a single field
export function validateField(value: any, rules: ValidationRule): ValidationResult {
// Convert value to string for most validations
const stringValue = value != null ? String(value).trim() : '';
// Required validation
if (rules.required && (!value || stringValue === '')) {
return { isValid: false, error: ERROR_MESSAGES.required };
}
// Skip other validations if value is empty and not required
if (!rules.required && (!value || stringValue === '')) {
return { isValid: true };
}
// Email validation
if (rules.email && !PATTERNS.email.test(stringValue)) {
return { isValid: false, error: ERROR_MESSAGES.email };
}
// Phone validation
if (rules.phone && !PATTERNS.phone.test(stringValue)) {
return { isValid: false, error: ERROR_MESSAGES.phone };
}
// URL validation
if (rules.url && !PATTERNS.url.test(stringValue)) {
return { isValid: false, error: ERROR_MESSAGES.url };
}
// Pattern validation
if (rules.pattern && !rules.pattern.test(stringValue)) {
return { isValid: false, error: ERROR_MESSAGES.pattern };
}
// Length validations
if (rules.minLength && stringValue.length < rules.minLength) {
return { isValid: false, error: ERROR_MESSAGES.minLength(rules.minLength) };
}
if (rules.maxLength && stringValue.length > rules.maxLength) {
return { isValid: false, error: ERROR_MESSAGES.maxLength(rules.maxLength) };
}
// Numeric validations
if (rules.min !== undefined || rules.max !== undefined) {
const numValue = Number(value);
if (isNaN(numValue)) {
return { isValid: false, error: ERROR_MESSAGES.number };
}
if (rules.min !== undefined && numValue < rules.min) {
return { isValid: false, error: ERROR_MESSAGES.min(rules.min) };
}
if (rules.max !== undefined && numValue > rules.max) {
return { isValid: false, error: ERROR_MESSAGES.max(rules.max) };
}
}
// Custom validation
if (rules.custom) {
const customError = rules.custom(value);
if (customError) {
return { isValid: false, error: customError };
}
}
return { isValid: true };
}
// Validate multiple fields
export function validateFields(data: Record<string, any>, rules: Record<string, ValidationRule>): {
isValid: boolean;
errors: Record<string, string>;
} {
const errors: Record<string, string> = {};
Object.entries(rules).forEach(([fieldName, fieldRules]) => {
const result = validateField(data[fieldName], fieldRules);
if (!result.isValid && result.error) {
errors[fieldName] = result.error;
}
});
return {
isValid: Object.keys(errors).length === 0,
errors,
};
}
// Specific validation functions
export function validateEmail(email: string): ValidationResult {
return validateField(email, { required: true, email: true });
}
export function validatePhone(phone: string): ValidationResult {
return validateField(phone, { phone: true });
}
export function validatePassword(password: string, minLength = 8): ValidationResult {
return validateField(password, { required: true, minLength });
}
export function validateUsername(username: string): ValidationResult {
return validateField(username, {
required: true,
minLength: 3,
pattern: PATTERNS.username
});
}
export function validatePlateNumber(plateNumber: string): ValidationResult {
return validateField(plateNumber, {
required: true,
pattern: PATTERNS.plateNumber
});
}
export function validateYear(year: number, minYear = 1900, maxYear = new Date().getFullYear() + 1): ValidationResult {
return validateField(year, {
required: true,
min: minYear,
max: maxYear
});
}
export function validateCurrency(amount: number, min = 0, max = 999999999): ValidationResult {
return validateField(amount, {
required: true,
min,
max
});
}
// Form data sanitization
export function sanitizeString(value: string): string {
return value.trim().replace(/\s+/g, ' ');
}
export function sanitizeNumber(value: string | number): number | null {
const num = Number(value);
return isNaN(num) ? null : num;
}
export function sanitizeInteger(value: string | number): number | null {
const num = parseInt(String(value));
return isNaN(num) ? null : num;
}
export function sanitizeFormData(data: Record<string, any>): Record<string, any> {
const sanitized: Record<string, any> = {};
Object.entries(data).forEach(([key, value]) => {
if (typeof value === 'string') {
sanitized[key] = sanitizeString(value);
} else {
sanitized[key] = value;
}
});
return sanitized;
}
// Date validation helpers
export function isValidDate(date: any): boolean {
return date instanceof Date && !isNaN(date.getTime());
}
export function validateDateRange(startDate: Date, endDate: Date): ValidationResult {
if (!isValidDate(startDate) || !isValidDate(endDate)) {
return { isValid: false, error: ERROR_MESSAGES.date };
}
if (startDate >= endDate) {
return { isValid: false, error: 'تاريخ البداية يجب أن يكون قبل تاريخ النهاية' };
}
return { isValid: true };
}
// File validation helpers
export function validateFileSize(file: File, maxSizeInMB: number): ValidationResult {
const maxSizeInBytes = maxSizeInMB * 1024 * 1024;
if (file.size > maxSizeInBytes) {
return {
isValid: false,
error: `حجم الملف يجب أن يكون أقل من ${maxSizeInMB} ميجابايت`
};
}
return { isValid: true };
}
export function validateFileType(file: File, allowedTypes: string[]): ValidationResult {
if (!allowedTypes.includes(file.type)) {
return {
isValid: false,
error: `نوع الملف غير مدعوم. الأنواع المدعومة: ${allowedTypes.join(', ')}`
};
}
return { isValid: true };
}
// Array validation helpers
export function validateArrayLength(array: any[], min?: number, max?: number): ValidationResult {
if (min !== undefined && array.length < min) {
return {
isValid: false,
error: `يجب اختيار ${min} عنصر على الأقل`
};
}
if (max !== undefined && array.length > max) {
return {
isValid: false,
error: `يجب اختيار ${max} عنصر كحد أقصى`
};
}
return { isValid: true };
}
// Conditional validation
export function validateConditional(
value: any,
condition: boolean,
rules: ValidationRule
): ValidationResult {
if (!condition) {
return { isValid: true };
}
return validateField(value, rules);
}

342
app/lib/validation.ts Normal file
View File

@@ -0,0 +1,342 @@
import { VALIDATION, AUTH_LEVELS, USER_STATUS, TRANSMISSION_TYPES, FUEL_TYPES, USE_TYPES, PAYMENT_STATUS } from './constants';
// User validation
export function validateUser(data: {
name?: string;
username?: string;
email?: string;
password?: string;
authLevel?: number;
status?: string;
}) {
const errors: Record<string, string> = {};
if (data.name !== undefined) {
if (!data.name || data.name.trim().length === 0) {
errors.name = 'الاسم مطلوب';
} else if (data.name.length > VALIDATION.MAX_NAME_LENGTH) {
errors.name = `الاسم يجب أن يكون أقل من ${VALIDATION.MAX_NAME_LENGTH} حرف`;
}
}
if (data.username !== undefined) {
if (!data.username || data.username.trim().length === 0) {
errors.username = 'اسم المستخدم مطلوب';
} else if (data.username.length < 3) {
errors.username = 'اسم المستخدم يجب أن يكون على الأقل 3 أحرف';
} else if (!/^[a-zA-Z0-9_]+$/.test(data.username)) {
errors.username = 'اسم المستخدم يجب أن يحتوي على أحرف وأرقام فقط';
}
}
if (data.email !== undefined) {
if (!data.email || data.email.trim().length === 0) {
errors.email = 'البريد الإلكتروني مطلوب';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.email = 'البريد الإلكتروني غير صحيح';
}
}
if (data.password !== undefined) {
if (!data.password || data.password.length === 0) {
errors.password = 'كلمة المرور مطلوبة';
} else if (data.password.length < VALIDATION.MIN_PASSWORD_LENGTH) {
errors.password = `كلمة المرور يجب أن تكون على الأقل ${VALIDATION.MIN_PASSWORD_LENGTH} أحرف`;
}
}
if (data.authLevel !== undefined) {
if (!Object.values(AUTH_LEVELS).includes(data.authLevel as any)) {
errors.authLevel = 'مستوى الصلاحية غير صحيح';
}
}
if (data.status !== undefined) {
if (!Object.values(USER_STATUS).includes(data.status as any)) {
errors.status = 'حالة المستخدم غير صحيحة';
}
}
return {
isValid: Object.keys(errors).length === 0,
errors,
};
}
// Customer validation
export function validateCustomer(data: {
name?: string;
phone?: string;
email?: string;
address?: string;
}) {
const errors: Record<string, string> = {};
if (data.name !== undefined) {
if (!data.name || data.name.trim().length === 0) {
errors.name = 'اسم العميل مطلوب';
} else if (data.name.length > VALIDATION.MAX_NAME_LENGTH) {
errors.name = `الاسم يجب أن يكون أقل من ${VALIDATION.MAX_NAME_LENGTH} حرف`;
}
}
if (data.phone && data.phone.trim().length > 0) {
if (!/^[\+]?[0-9\s\-\(\)]+$/.test(data.phone)) {
errors.phone = 'رقم الهاتف غير صحيح';
}
}
if (data.email && data.email.trim().length > 0) {
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.email = 'البريد الإلكتروني غير صحيح';
}
}
return {
isValid: Object.keys(errors).length === 0,
errors,
};
}
// Vehicle validation
export function validateVehicle(data: {
plateNumber?: string;
bodyType?: string;
manufacturer?: string;
model?: string;
year?: number;
transmission?: string;
fuel?: string;
cylinders?: number;
engineDisplacement?: number;
useType?: string;
ownerId?: number;
}) {
const errors: Record<string, string> = {};
if (data.plateNumber !== undefined) {
if (!data.plateNumber || data.plateNumber.trim().length === 0) {
errors.plateNumber = 'رقم اللوحة مطلوب';
}
}
if (data.bodyType !== undefined) {
if (!data.bodyType || data.bodyType.trim().length === 0) {
errors.bodyType = 'نوع الهيكل مطلوب';
}
}
if (data.manufacturer !== undefined) {
if (!data.manufacturer || data.manufacturer.trim().length === 0) {
errors.manufacturer = 'الشركة المصنعة مطلوبة';
}
}
if (data.model !== undefined) {
if (!data.model || data.model.trim().length === 0) {
errors.model = 'الموديل مطلوب';
}
}
if (data.year !== undefined) {
if (!data.year || data.year < VALIDATION.MIN_YEAR || data.year > VALIDATION.MAX_YEAR) {
errors.year = `السنة يجب أن تكون بين ${VALIDATION.MIN_YEAR} و ${VALIDATION.MAX_YEAR}`;
}
}
if (data.transmission !== undefined) {
const validTransmissions = TRANSMISSION_TYPES.map(t => t.value);
if (!validTransmissions.includes(data.transmission as any)) {
errors.transmission = 'نوع ناقل الحركة غير صحيح';
}
}
if (data.fuel !== undefined) {
const validFuels = FUEL_TYPES.map(f => f.value);
if (!validFuels.includes(data.fuel as any)) {
errors.fuel = 'نوع الوقود غير صحيح';
}
}
if (data.cylinders !== undefined && data.cylinders !== null) {
if (data.cylinders < 1 || data.cylinders > VALIDATION.MAX_CYLINDERS) {
errors.cylinders = `عدد الأسطوانات يجب أن يكون بين 1 و ${VALIDATION.MAX_CYLINDERS}`;
}
}
if (data.engineDisplacement !== undefined && data.engineDisplacement !== null) {
if (data.engineDisplacement <= 0 || data.engineDisplacement > VALIDATION.MAX_ENGINE_DISPLACEMENT) {
errors.engineDisplacement = `سعة المحرك يجب أن تكون بين 0.1 و ${VALIDATION.MAX_ENGINE_DISPLACEMENT}`;
}
}
if (data.useType !== undefined) {
const validUseTypes = USE_TYPES.map(u => u.value);
if (!validUseTypes.includes(data.useType as any)) {
errors.useType = 'نوع الاستخدام غير صحيح';
}
}
if (data.ownerId !== undefined) {
if (!data.ownerId || data.ownerId <= 0) {
errors.ownerId = 'مالك المركبة مطلوب';
}
}
return {
isValid: Object.keys(errors).length === 0,
errors,
};
}
// Maintenance visit validation
export function validateMaintenanceVisit(data: {
vehicleId?: number;
customerId?: number;
maintenanceJobs?: Array<{typeId: number; job: string; notes?: string}>;
description?: string;
cost?: number;
paymentStatus?: string;
kilometers?: number;
nextVisitDelay?: number;
}) {
const errors: Record<string, string> = {};
if (data.vehicleId !== undefined) {
if (!data.vehicleId || data.vehicleId <= 0) {
errors.vehicleId = 'المركبة مطلوبة';
}
}
if (data.customerId !== undefined) {
if (!data.customerId || data.customerId <= 0) {
errors.customerId = 'العميل مطلوب';
}
}
if (data.maintenanceJobs !== undefined) {
if (!data.maintenanceJobs || data.maintenanceJobs.length === 0) {
errors.maintenanceJobs = 'يجب إضافة عمل صيانة واحد على الأقل';
} else {
// Validate each maintenance job
const invalidJobs = data.maintenanceJobs.filter(job =>
!job.typeId || job.typeId <= 0 || !job.job || job.job.trim().length === 0
);
if (invalidJobs.length > 0) {
errors.maintenanceJobs = 'جميع أعمال الصيانة يجب أن تحتوي على نوع ووصف صحيح';
}
}
}
if (data.description !== undefined) {
if (!data.description || data.description.trim().length === 0) {
errors.description = 'وصف الصيانة مطلوب';
} else if (data.description.length > VALIDATION.MAX_DESCRIPTION_LENGTH) {
errors.description = `الوصف يجب أن يكون أقل من ${VALIDATION.MAX_DESCRIPTION_LENGTH} حرف`;
}
}
if (data.cost !== undefined) {
if (data.cost === null || data.cost < VALIDATION.MIN_COST || data.cost > VALIDATION.MAX_COST) {
errors.cost = `التكلفة يجب أن تكون بين ${VALIDATION.MIN_COST} و ${VALIDATION.MAX_COST}`;
}
}
if (data.paymentStatus !== undefined) {
if (!Object.values(PAYMENT_STATUS).includes(data.paymentStatus as any)) {
errors.paymentStatus = 'حالة الدفع غير صحيحة';
}
}
if (data.kilometers !== undefined) {
if (data.kilometers === null || data.kilometers < 0) {
errors.kilometers = 'عدد الكيلومترات يجب أن يكون رقم موجب';
}
}
if (data.nextVisitDelay !== undefined) {
if (!data.nextVisitDelay || ![1, 2, 3, 4].includes(data.nextVisitDelay)) {
errors.nextVisitDelay = 'فترة الزيارة التالية يجب أن تكون 1، 2، 3، أو 4 أشهر';
}
}
return {
isValid: Object.keys(errors).length === 0,
errors,
};
}
// Expense validation
export function validateExpense(data: {
description?: string;
category?: string;
amount?: number;
}) {
const errors: Record<string, string> = {};
if (data.description !== undefined) {
if (!data.description || data.description.trim().length === 0) {
errors.description = 'وصف المصروف مطلوب';
} else if (data.description.length > VALIDATION.MAX_DESCRIPTION_LENGTH) {
errors.description = `الوصف يجب أن يكون أقل من ${VALIDATION.MAX_DESCRIPTION_LENGTH} حرف`;
}
}
if (data.category !== undefined) {
if (!data.category || data.category.trim().length === 0) {
errors.category = 'فئة المصروف مطلوبة';
}
}
if (data.amount !== undefined) {
if (data.amount === null || data.amount <= VALIDATION.MIN_COST || data.amount > VALIDATION.MAX_COST) {
errors.amount = `المبلغ يجب أن يكون بين ${VALIDATION.MIN_COST + 0.01} و ${VALIDATION.MAX_COST}`;
}
}
return {
isValid: Object.keys(errors).length === 0,
errors,
};
}
// Generic validation helpers
export function isValidDate(date: any): date is Date {
return date instanceof Date && !isNaN(date.getTime());
}
export function isValidNumber(value: any): value is number {
return typeof value === 'number' && !isNaN(value) && isFinite(value);
}
export function isValidString(value: any, minLength = 1): value is string {
return typeof value === 'string' && value.trim().length >= minLength;
}
export function sanitizeString(value: string): string {
return value.trim().replace(/\s+/g, ' ');
}
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'JOD',
}).format(amount);
}
export function formatDate(date: Date, format: string = 'dd/MM/yyyy'): string {
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const year = date.getFullYear();
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
switch (format) {
case 'dd/MM/yyyy':
return `${day}/${month}/${year}`;
case 'dd/MM/yyyy HH:mm':
return `${day}/${month}/${year} ${hours}:${minutes}`;
default:
return date.toLocaleDateString('ar-SA');
}
}

View File

@@ -0,0 +1,368 @@
import { prisma } from "./db.server";
import type { Vehicle } from "@prisma/client";
import type { CreateVehicleData, UpdateVehicleData, VehicleWithOwner, VehicleWithRelations } from "~/types/database";
// Get all vehicles with search and pagination
export async function getVehicles(
searchQuery?: string,
page: number = 1,
limit: number = 10,
ownerId?: number,
plateNumber?: string
): Promise<{
vehicles: VehicleWithOwner[];
total: number;
totalPages: number;
}> {
const offset = (page - 1) * limit;
// Build where clause for search
const whereClause: any = {};
if (ownerId) {
whereClause.ownerId = ownerId;
}
if (plateNumber) {
whereClause.plateNumber = { contains: plateNumber.toLowerCase() };
}
if (searchQuery) {
const searchLower = searchQuery.toLowerCase();
whereClause.OR = [
{ plateNumber: { contains: searchLower } },
{ manufacturer: { contains: searchLower } },
{ model: { contains: searchLower } },
{ bodyType: { contains: searchLower } },
{ owner: { name: { contains: searchLower } } },
];
}
const [vehicles, total] = await Promise.all([
prisma.vehicle.findMany({
where: whereClause,
include: {
owner: {
select: {
id: true,
name: true,
phone: true,
email: true,
},
},
},
orderBy: { createdDate: 'desc' },
skip: offset,
take: limit,
}),
prisma.vehicle.count({ where: whereClause }),
]);
return {
vehicles,
total,
totalPages: Math.ceil(total / limit),
};
}
// Get vehicle by ID with full relationships
export async function getVehicleById(id: number): Promise<VehicleWithRelations | null> {
return await prisma.vehicle.findUnique({
where: { id },
include: {
owner: true,
maintenanceVisits: {
orderBy: { visitDate: 'desc' },
take: 10,
},
},
});
}
// Create new vehicle
export async function createVehicle(
vehicleData: CreateVehicleData
): Promise<{ success: boolean; vehicle?: Vehicle; error?: string }> {
try {
// Check if vehicle with same plate number already exists
const existingVehicle = await prisma.vehicle.findUnique({
where: { plateNumber: vehicleData.plateNumber },
});
if (existingVehicle) {
return { success: false, error: "رقم اللوحة موجود بالفعل" };
}
// Check if owner exists
const owner = await prisma.customer.findUnique({
where: { id: vehicleData.ownerId },
});
if (!owner) {
return { success: false, error: "المالك غير موجود" };
}
// Create vehicle
const vehicle = await prisma.vehicle.create({
data: {
plateNumber: vehicleData.plateNumber.trim(),
bodyType: vehicleData.bodyType.trim(),
manufacturer: vehicleData.manufacturer.trim(),
model: vehicleData.model.trim(),
trim: vehicleData.trim?.trim() || null,
year: vehicleData.year,
transmission: vehicleData.transmission,
fuel: vehicleData.fuel,
cylinders: vehicleData.cylinders || null,
engineDisplacement: vehicleData.engineDisplacement || null,
useType: vehicleData.useType,
ownerId: vehicleData.ownerId,
},
});
return { success: true, vehicle };
} catch (error) {
console.error("Error creating vehicle:", error);
return { success: false, error: "حدث خطأ أثناء إنشاء المركبة" };
}
}
// Update vehicle
export async function updateVehicle(
id: number,
vehicleData: UpdateVehicleData
): Promise<{ success: boolean; vehicle?: Vehicle; error?: string }> {
try {
// Check if vehicle exists
const existingVehicle = await prisma.vehicle.findUnique({
where: { id },
});
if (!existingVehicle) {
return { success: false, error: "المركبة غير موجودة" };
}
// Check for plate number conflicts with other vehicles
if (vehicleData.plateNumber) {
const conflictVehicle = await prisma.vehicle.findFirst({
where: {
AND: [
{ id: { not: id } },
{ plateNumber: vehicleData.plateNumber },
],
},
});
if (conflictVehicle) {
return { success: false, error: "رقم اللوحة موجود بالفعل" };
}
}
// Check if new owner exists (if changing owner)
if (vehicleData.ownerId && vehicleData.ownerId !== existingVehicle.ownerId) {
const owner = await prisma.customer.findUnique({
where: { id: vehicleData.ownerId },
});
if (!owner) {
return { success: false, error: "المالك الجديد غير موجود" };
}
}
// Prepare update data
const updateData: any = {};
if (vehicleData.plateNumber !== undefined) updateData.plateNumber = vehicleData.plateNumber.trim();
if (vehicleData.bodyType !== undefined) updateData.bodyType = vehicleData.bodyType.trim();
if (vehicleData.manufacturer !== undefined) updateData.manufacturer = vehicleData.manufacturer.trim();
if (vehicleData.model !== undefined) updateData.model = vehicleData.model.trim();
if (vehicleData.trim !== undefined) updateData.trim = vehicleData.trim?.trim() || null;
if (vehicleData.year !== undefined) updateData.year = vehicleData.year;
if (vehicleData.transmission !== undefined) updateData.transmission = vehicleData.transmission;
if (vehicleData.fuel !== undefined) updateData.fuel = vehicleData.fuel;
if (vehicleData.cylinders !== undefined) updateData.cylinders = vehicleData.cylinders || null;
if (vehicleData.engineDisplacement !== undefined) updateData.engineDisplacement = vehicleData.engineDisplacement || null;
if (vehicleData.useType !== undefined) updateData.useType = vehicleData.useType;
if (vehicleData.ownerId !== undefined) updateData.ownerId = vehicleData.ownerId;
if (vehicleData.lastVisitDate !== undefined) updateData.lastVisitDate = vehicleData.lastVisitDate;
if (vehicleData.suggestedNextVisitDate !== undefined) updateData.suggestedNextVisitDate = vehicleData.suggestedNextVisitDate;
// Update vehicle
const vehicle = await prisma.vehicle.update({
where: { id },
data: updateData,
});
return { success: true, vehicle };
} catch (error) {
console.error("Error updating vehicle:", error);
return { success: false, error: "حدث خطأ أثناء تحديث المركبة" };
}
}
// Delete vehicle with relationship handling
export async function deleteVehicle(
id: number
): Promise<{ success: boolean; error?: string }> {
try {
// Check if vehicle exists
const existingVehicle = await prisma.vehicle.findUnique({
where: { id },
include: {
maintenanceVisits: true,
},
});
if (!existingVehicle) {
return { success: false, error: "المركبة غير موجودة" };
}
// Check if vehicle has maintenance visits
if (existingVehicle.maintenanceVisits.length > 0) {
return {
success: false,
error: `لا يمكن حذف المركبة لأنها تحتوي على ${existingVehicle.maintenanceVisits.length} زيارة صيانة. يرجى حذف الزيارات أولاً`
};
}
// Delete vehicle
await prisma.vehicle.delete({
where: { id },
});
return { success: true };
} catch (error) {
console.error("Error deleting vehicle:", error);
return { success: false, error: "حدث خطأ أثناء حذف المركبة" };
}
}
// Get vehicles for dropdown/select options
export async function getVehiclesForSelect(ownerId?: number): Promise<{
id: number;
plateNumber: string;
manufacturer: string;
model: string;
year: number;
}[]> {
const whereClause = ownerId ? { ownerId } : {};
return await prisma.vehicle.findMany({
where: whereClause,
select: {
id: true,
plateNumber: true,
manufacturer: true,
model: true,
year: true,
},
orderBy: { plateNumber: 'asc' },
});
}
// Get vehicle statistics
export async function getVehicleStats(vehicleId: number): Promise<{
totalVisits: number;
totalSpent: number;
lastVisitDate?: Date;
nextSuggestedVisitDate?: Date;
averageVisitCost: number;
} | null> {
const vehicle = await prisma.vehicle.findUnique({
where: { id: vehicleId },
include: {
maintenanceVisits: {
select: {
cost: true,
visitDate: true,
},
orderBy: { visitDate: 'desc' },
},
},
});
if (!vehicle) return null;
const totalSpent = vehicle.maintenanceVisits.reduce((sum, visit) => sum + visit.cost, 0);
const averageVisitCost = vehicle.maintenanceVisits.length > 0
? totalSpent / vehicle.maintenanceVisits.length
: 0;
const lastVisitDate = vehicle.maintenanceVisits.length > 0
? vehicle.maintenanceVisits[0].visitDate
: undefined;
return {
totalVisits: vehicle.maintenanceVisits.length,
totalSpent,
lastVisitDate,
nextSuggestedVisitDate: vehicle.suggestedNextVisitDate || undefined,
averageVisitCost,
};
}
// Search vehicles by plate number or manufacturer (for autocomplete)
export async function searchVehicles(query: string, limit: number = 10): Promise<{
id: number;
plateNumber: string;
manufacturer: string;
model: string;
year: number;
owner: { id: number; name: string; };
}[]> {
if (!query || query.trim().length < 2) {
return [];
}
const searchLower = query.toLowerCase();
return await prisma.vehicle.findMany({
where: {
OR: [
{ plateNumber: { contains: searchLower } },
{ manufacturer: { contains: searchLower } },
{ model: { contains: searchLower } },
],
},
select: {
id: true,
plateNumber: true,
manufacturer: true,
model: true,
year: true,
owner: {
select: {
id: true,
name: true,
},
},
},
orderBy: { plateNumber: 'asc' },
take: limit,
});
}
// Get vehicles by owner ID
export async function getVehiclesByOwner(ownerId: number): Promise<VehicleWithOwner[]> {
return await prisma.vehicle.findMany({
where: { ownerId },
include: {
owner: {
select: {
id: true,
name: true,
phone: true,
email: true,
},
},
},
orderBy: { createdDate: 'desc' },
});
}
export async function getMaintenanceVisitsByVehicle(vehicleId: number) {
const visits = await prisma.maintenanceVisit.findMany({
where: { vehicleId: vehicleId },
orderBy: { createdDate: 'desc' },
});
return visits
}

84
app/root.tsx Normal file
View File

@@ -0,0 +1,84 @@
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { getAppSettings, initializeDefaultSettings } from "~/lib/settings-management.server";
import { SettingsProvider } from "~/contexts/SettingsContext";
import "./tailwind.css";
export const links: LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Cairo:wght@200;300;400;500;600;700;800;900&display=swap",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Noto+Sans+Arabic:wght@100;200;300;400;500;600;700;800;900&display=swap",
},
];
export async function loader({ request }: LoaderFunctionArgs) {
try {
// Initialize default settings if needed
await initializeDefaultSettings();
const settings = await getAppSettings();
return json({ settings });
} catch (error) {
console.error('Root loader error:', error);
// Return default settings if there's an error
return json({
settings: {
dateFormat: 'ar-SA' as const,
currency: 'JOD',
numberFormat: 'ar-SA' as const,
currencySymbol: 'د.أ',
dateDisplayFormat: 'dd/MM/yyyy'
}
});
}
}
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="ar" dir="rtl">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="font-arabic">
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
const { settings } = useLoaderData<typeof loader>();
return (
<SettingsProvider settings={settings}>
<Outlet />
</SettingsProvider>
);
}

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

170
app/tailwind.css Normal file
View File

@@ -0,0 +1,170 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
@apply bg-white dark:bg-gray-950;
@media (prefers-color-scheme: dark) {
color-scheme: dark;
}
}
/* RTL Support */
[dir="rtl"] {
direction: rtl;
text-align: right;
}
[dir="ltr"] {
direction: ltr;
text-align: left;
}
/* Arabic Text Rendering */
.arabic-text {
font-feature-settings: "liga" 1, "kern" 1;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* RTL-specific utilities */
@layer utilities {
.rtl-flip {
transform: scaleX(-1);
}
.rtl-space-x-reverse > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 1;
}
.rtl-divide-x-reverse > :not([hidden]) ~ :not([hidden]) {
--tw-divide-x-reverse: 1;
}
}
/* Responsive breakpoints for RTL */
@layer components {
.container-rtl {
@apply container mx-auto px-4 sm:px-6 lg:px-8;
}
.grid-rtl {
@apply grid gap-4 sm:gap-6 lg:gap-8;
}
.flex-rtl {
@apply flex items-center;
}
.flex-rtl-reverse {
@apply flex items-center flex-row-reverse;
}
}
/* Form elements RTL support */
input[type="text"],
input[type="email"],
input[type="password"],
input[type="number"],
input[type="tel"],
textarea,
select {
direction: rtl;
text-align: right;
}
input[type="text"]:focus,
input[type="email"]:focus,
input[type="password"]:focus,
input[type="number"]:focus,
input[type="tel"]:focus,
textarea:focus,
select:focus {
@apply ring-2 ring-blue-500 ring-opacity-50;
}
/* Button and interactive elements */
button,
.btn {
@apply transition-all duration-200 ease-in-out;
}
/* Navigation and layout components */
.sidebar-transition {
@apply transition-all duration-300 ease-in-out;
}
/* Sidebar hover effects */
.sidebar-item {
@apply transition-colors duration-150 ease-in-out;
}
.sidebar-item:hover {
@apply bg-gray-50 text-gray-900;
}
.sidebar-item.active {
@apply bg-blue-100 text-blue-900 border-r-2 border-blue-600;
}
/* Mobile menu overlay */
.mobile-overlay {
@apply fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity duration-300;
}
/* Smooth transitions for layout changes */
.layout-transition {
@apply transition-all duration-300 ease-in-out;
}
/* Focus styles for accessibility */
.focus-ring {
@apply focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2;
}
/* Custom scrollbar for sidebar */
.sidebar-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.sidebar-scroll::-webkit-scrollbar {
width: 6px;
}
.sidebar-scroll::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-scroll::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 3px;
}
.sidebar-scroll::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.7);
}
/* RTL Layout fixes */
[dir="rtl"] .space-x-reverse > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 1;
}
/* Ensure proper RTL text alignment */
[dir="rtl"] {
text-align: right;
}
/* Fix specific margin issues for RTL */
[dir="rtl"] .ml-1 {
margin-right: 0.25rem;
margin-left: 0;
}
[dir="rtl"] .ml-3 {
margin-right: 0.75rem;
margin-left: 0;
}

61
app/types/auth.ts Normal file
View File

@@ -0,0 +1,61 @@
import type { User } from "@prisma/client";
// Authentication levels
export const AUTH_LEVELS = {
SUPERADMIN: 1,
ADMIN: 2,
USER: 3,
} as const;
export type AuthLevel = typeof AUTH_LEVELS[keyof typeof AUTH_LEVELS];
// User status types
export const USER_STATUS = {
ACTIVE: "active",
INACTIVE: "inactive",
} as const;
export type UserStatus = typeof USER_STATUS[keyof typeof USER_STATUS];
// Authentication form data types
export interface SignInFormData {
usernameOrEmail: string;
password: string;
redirectTo?: string;
}
export interface SignUpFormData {
name: string;
username: string;
email: string;
password: string;
confirmPassword: string;
}
// User data without sensitive information
export type SafeUser = Omit<User, "password">;
// Authentication error types
export interface AuthError {
field?: string;
message: string;
}
// Authentication result types
export interface AuthResult {
success: boolean;
user?: SafeUser;
errors?: AuthError[];
}
// Session data type
export interface SessionData {
userId: number;
}
// Route protection types
export interface RouteProtectionOptions {
requiredAuthLevel?: AuthLevel;
allowInactive?: boolean;
redirectTo?: string;
}

290
app/types/database.ts Normal file
View File

@@ -0,0 +1,290 @@
import type { User, Customer, Vehicle, MaintenanceVisit, MaintenanceType, Expense, Income, CarDataset, Settings } from '@prisma/client';
// Re-export Prisma types for easier imports
export type {
User,
Customer,
Vehicle,
MaintenanceVisit,
MaintenanceType,
Expense,
Income,
CarDataset,
Settings,
} from '@prisma/client';
// Extended types with relationships
export type UserWithoutPassword = Omit<User, 'password'>;
export type CustomerWithVehicles = Customer & {
vehicles: Vehicle[];
maintenanceVisits: (MaintenanceVisit & {
vehicle: {
id: number;
plateNumber: string;
manufacturer: string;
model: string;
year: number;
};
})[];
};
export type VehicleWithOwner = Vehicle & {
owner: Customer;
};
export type VehicleWithRelations = Vehicle & {
owner: Customer;
maintenanceVisits: MaintenanceVisit[];
};
// Maintenance job interface for JSON field
export interface MaintenanceJob {
typeId: number;
job: string;
cost: number;
notes?: string;
}
export type MaintenanceVisitWithRelations = MaintenanceVisit & {
vehicle: Vehicle;
customer: Customer;
income: Income[];
// maintenanceJobs will be parsed from JSON string
};
export type IncomeWithVisit = Income & {
maintenanceVisit: MaintenanceVisit;
};
// Enums for form validation and type safety
export const UserStatus = {
ACTIVE: 'active',
INACTIVE: 'inactive',
} as const;
export const AuthLevel = {
SUPERADMIN: 1,
ADMIN: 2,
USER: 3,
} as const;
export const TransmissionType = {
AUTOMATIC: 'Automatic',
MANUAL: 'Manual',
} as const;
export const FuelType = {
GASOLINE: 'Gasoline',
DIESEL: 'Diesel',
HYBRID: 'Hybrid',
MILD_HYBRID: 'Mild Hybrid',
ELECTRIC: 'Electric',
} as const;
export const UseType = {
PERSONAL: 'personal',
TAXI: 'taxi',
APPS: 'apps',
LOADING: 'loading',
TRAVEL: 'travel',
} as const;
export const PaymentStatus = {
PENDING: 'pending',
PAID: 'paid',
PARTIAL: 'partial',
CANCELLED: 'cancelled',
} as const;
// Form data types for validation
export interface CreateUserData {
name: string;
username: string;
email: string;
password: string;
authLevel: number;
status?: string;
}
export interface UpdateUserData {
name?: string;
username?: string;
email?: string;
password?: string;
authLevel?: number;
status?: string;
}
export interface CreateCustomerData {
name: string;
phone?: string;
email?: string;
address?: string;
}
export interface UpdateCustomerData {
name?: string;
phone?: string;
email?: string;
address?: string;
}
export interface CreateVehicleData {
plateNumber: string;
bodyType: string;
manufacturer: string;
model: string;
trim?: string;
year: number;
transmission: string;
fuel: string;
cylinders?: number;
engineDisplacement?: number;
useType: string;
ownerId: number;
}
export interface UpdateVehicleData {
plateNumber?: string;
bodyType?: string;
manufacturer?: string;
model?: string;
trim?: string;
year?: number;
transmission?: string;
fuel?: string;
cylinders?: number;
engineDisplacement?: number;
useType?: string;
ownerId?: number;
lastVisitDate?: Date;
suggestedNextVisitDate?: Date;
}
export interface CreateMaintenanceTypeData {
name: string;
description?: string;
isActive?: boolean;
}
export interface UpdateMaintenanceTypeData {
name?: string;
description?: string;
isActive?: boolean;
}
export interface CreateMaintenanceVisitData {
vehicleId: number;
customerId: number;
maintenanceJobs: MaintenanceJob[];
description: string;
cost: number;
paymentStatus?: string;
kilometers: number;
visitDate?: Date;
nextVisitDelay: number;
}
export interface UpdateMaintenanceVisitData {
vehicleId?: number;
customerId?: number;
maintenanceJobs?: MaintenanceJob[];
description?: string;
cost?: number;
paymentStatus?: string;
kilometers?: number;
visitDate?: Date;
nextVisitDelay?: number;
}
export interface CreateExpenseData {
description: string;
category: string;
amount: number;
expenseDate?: Date;
}
export interface UpdateExpenseData {
description?: string;
category?: string;
amount?: number;
expenseDate?: Date;
}
export interface CreateIncomeData {
maintenanceVisitId: number;
amount: number;
incomeDate?: Date;
}
export interface CreateCarDatasetData {
manufacturer: string;
model: string;
bodyType: string;
isActive?: boolean;
}
export interface UpdateCarDatasetData {
manufacturer?: string;
model?: string;
bodyType?: string;
isActive?: boolean;
}
// Utility types for API responses
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: any;
};
}
export interface PaginatedResponse<T = any> {
data: T[];
pagination: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
// Search and filter types
export interface SearchParams {
query?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}
export interface CustomerSearchParams extends SearchParams {
status?: string;
}
export interface VehicleSearchParams extends SearchParams {
manufacturer?: string;
year?: number;
useType?: string;
ownerId?: number;
}
export interface MaintenanceVisitSearchParams extends SearchParams {
vehicleId?: number;
customerId?: number;
dateFrom?: Date;
dateTo?: Date;
paymentStatus?: string;
}
export interface FinancialSearchParams extends SearchParams {
category?: string;
dateFrom?: Date;
dateTo?: Date;
amountMin?: number;
amountMax?: number;
}