uup
This commit is contained in:
363
app/components/README.md
Normal file
363
app/components/README.md
Normal 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
|
||||
```
|
||||
313
app/components/customers/CustomerDetailsView.tsx
Normal file
313
app/components/customers/CustomerDetailsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
183
app/components/customers/CustomerForm.tsx
Normal file
183
app/components/customers/CustomerForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
282
app/components/customers/CustomerList.tsx
Normal file
282
app/components/customers/CustomerList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
195
app/components/expenses/ExpenseForm.tsx
Normal file
195
app/components/expenses/ExpenseForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
app/components/forms/EnhancedCustomerForm.tsx
Normal file
199
app/components/forms/EnhancedCustomerForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
400
app/components/forms/EnhancedVehicleForm.tsx
Normal file
400
app/components/forms/EnhancedVehicleForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
app/components/layout/Container.tsx
Normal file
41
app/components/layout/Container.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
app/components/layout/DashboardLayout.tsx
Normal file
170
app/components/layout/DashboardLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
app/components/layout/Flex.tsx
Normal file
102
app/components/layout/Flex.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
app/components/layout/Grid.tsx
Normal file
56
app/components/layout/Grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
257
app/components/layout/Sidebar.tsx
Normal file
257
app/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
app/components/layout/index.ts
Normal file
5
app/components/layout/index.ts
Normal 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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
564
app/components/maintenance-visits/MaintenanceVisitForm.tsx
Normal file
564
app/components/maintenance-visits/MaintenanceVisitForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
253
app/components/maintenance-visits/MaintenanceVisitList.tsx
Normal file
253
app/components/maintenance-visits/MaintenanceVisitList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
app/components/maintenance-visits/index.ts
Normal file
2
app/components/maintenance-visits/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MaintenanceVisitForm } from './MaintenanceVisitForm';
|
||||
export { MaintenanceVisitList } from './MaintenanceVisitList';
|
||||
212
app/components/tables/EnhancedCustomerTable.tsx
Normal file
212
app/components/tables/EnhancedCustomerTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
214
app/components/ui/AutocompleteInput.tsx
Normal file
214
app/components/ui/AutocompleteInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
app/components/ui/Button.tsx
Normal file
92
app/components/ui/Button.tsx
Normal 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
118
app/components/ui/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
422
app/components/ui/DataTable.tsx
Normal file
422
app/components/ui/DataTable.tsx
Normal 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
233
app/components/ui/Form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
app/components/ui/FormField.tsx
Normal file
54
app/components/ui/FormField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
app/components/ui/Input.tsx
Normal file
86
app/components/ui/Input.tsx
Normal 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
220
app/components/ui/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
171
app/components/ui/MultiSelect.tsx
Normal file
171
app/components/ui/MultiSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
app/components/ui/SearchInput.tsx
Normal file
79
app/components/ui/SearchInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
app/components/ui/Select.tsx
Normal file
97
app/components/ui/Select.tsx
Normal 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';
|
||||
73
app/components/ui/Text.tsx
Normal file
73
app/components/ui/Text.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
app/components/ui/Textarea.tsx
Normal file
72
app/components/ui/Textarea.tsx
Normal 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';
|
||||
10
app/components/ui/index.ts
Normal file
10
app/components/ui/index.ts
Normal 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';
|
||||
217
app/components/users/UserForm.tsx
Normal file
217
app/components/users/UserForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
app/components/users/UserList.tsx
Normal file
233
app/components/users/UserList.tsx
Normal 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'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
388
app/components/vehicles/VehicleDetailsView.tsx
Normal file
388
app/components/vehicles/VehicleDetailsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
576
app/components/vehicles/VehicleForm.tsx
Normal file
576
app/components/vehicles/VehicleForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
272
app/components/vehicles/VehicleList.tsx
Normal file
272
app/components/vehicles/VehicleList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user