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