This commit is contained in:
2026-01-23 20:35:40 +03:00
parent cf3b0e48ec
commit 66c151653e
137 changed files with 41495 additions and 0 deletions

84
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,84 @@
/**
* This is intended to be a basic starting point for linting in your app.
* It relies on recommended configs out of the box for simplicity, but you can
* and should modify this configuration to best suit your team's needs.
*/
/** @type {import('eslint').Linter.Config} */
module.exports = {
root: true,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
env: {
browser: true,
commonjs: true,
es6: true,
},
ignorePatterns: ["!**/.server", "!**/.client"],
// Base config
extends: ["eslint:recommended"],
overrides: [
// React
{
files: ["**/*.{js,jsx,ts,tsx}"],
plugins: ["react", "jsx-a11y"],
extends: [
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended",
"plugin:jsx-a11y/recommended",
],
settings: {
react: {
version: "detect",
},
formComponents: ["Form"],
linkComponents: [
{ name: "Link", linkAttribute: "to" },
{ name: "NavLink", linkAttribute: "to" },
],
"import/resolver": {
typescript: {},
},
},
},
// Typescript
{
files: ["**/*.{ts,tsx}"],
plugins: ["@typescript-eslint", "import"],
parser: "@typescript-eslint/parser",
settings: {
"import/internal-regex": "^~/",
"import/resolver": {
node: {
extensions: [".ts", ".tsx"],
},
typescript: {
alwaysTryTypes: true,
},
},
},
extends: [
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
],
},
// Node
{
files: [".eslintrc.cjs"],
env: {
node: true,
},
},
],
};

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules
/.cache
/build
.env
/generated/prisma

View File

@@ -0,0 +1,399 @@
# Design Document
## Overview
The car maintenance management system is designed as a modern, responsive web application built with Remix, featuring Arabic language support with RTL layout, role-based authentication, and comprehensive business management capabilities. The system follows a modular architecture with clear separation of concerns, utilizing Prisma for database operations and Tailwind CSS for styling.
## Architecture
### High-Level Architecture
```mermaid
graph TB
A[Client Browser] --> B[Remix Frontend]
B --> C[Remix Backend/API Routes]
C --> D[Prisma ORM]
D --> E[SQLite Database]
F[Authentication Middleware] --> C
G[Session Management] --> C
H[Route Protection] --> B
subgraph "Frontend Components"
I[Dashboard Layout]
J[RTL Components]
K[Responsive Navigation]
L[Form Components]
end
B --> I
B --> J
B --> K
B --> L
```
### Technology Stack
- **Frontend Framework**: Remix with React 18
- **Backend**: Remix server-side rendering and API routes
- **Database**: SQLite with Prisma ORM
- **Styling**: Tailwind CSS with RTL support
- **Authentication**: Custom session-based authentication
- **TypeScript**: Full type safety throughout the application
## Components and Interfaces
### 1. Authentication System
#### Session Management
- Custom session-based authentication using Remix sessions
- Secure cookie storage with httpOnly and secure flags
- Session expiration and renewal mechanisms
- CSRF protection for form submissions
#### User Authentication Flow
```mermaid
sequenceDiagram
participant U as User
participant A as Auth Route
participant S as Session Store
participant D as Database
U->>A: Submit login credentials
A->>D: Validate user credentials
D-->>A: Return user data
A->>S: Create session
S-->>A: Return session cookie
A-->>U: Redirect with session cookie
```
#### Route Protection Middleware
- Higher-order component for protecting routes based on auth levels
- Automatic redirection to login for unauthenticated users
- Role-based access control for different user types
### 2. Database Schema Design
#### Core Entities and Relationships
```mermaid
erDiagram
User {
int id PK
string name
string username UK
string email UK
string password
enum status
int authLevel
datetime createdDate
datetime editDate
}
Customer {
int id PK
string name
string phone
string email
string address
datetime createdDate
datetime updateDate
}
Vehicle {
int id PK
string plateNumber UK
string bodyType
string manufacturer
string model
string trim
int year
string transmission
string fuel
int cylinders
decimal engineDisplacement
enum useType
int ownerId FK
datetime lastVisitDate
datetime suggestedNextVisitDate
datetime createdDate
datetime updateDate
}
MaintenanceVisit {
int id PK
int vehicleId FK
int customerId FK
string maintenanceType
string description
decimal cost
string paymentStatus
int kilometers
datetime visitDate
datetime createdDate
datetime updateDate
}
Expense {
int id PK
string description
string category
decimal amount
datetime expenseDate
datetime createdDate
datetime updateDate
}
Income {
int id PK
int maintenanceVisitId FK
decimal amount
datetime incomeDate
datetime createdDate
datetime updateDate
}
Customer ||--o{ Vehicle : owns
Vehicle ||--o{ MaintenanceVisit : has
Customer ||--o{ MaintenanceVisit : books
MaintenanceVisit ||--|| Income : generates
```
### 3. UI/UX Design System
#### RTL Layout Implementation
- CSS logical properties for directional layouts
- Arabic font integration (Noto Sans Arabic, Cairo)
- Tailwind CSS RTL plugin configuration
- Component-level RTL support with `dir="rtl"` attribute
#### Responsive Design Breakpoints
```css
/* Mobile First Approach */
sm: 640px /* Small devices */
md: 768px /* Medium devices */
lg: 1024px /* Large devices */
xl: 1280px /* Extra large devices */
2xl: 1536px /* 2X Extra large devices */
```
#### Color Scheme and Theming
- Primary colors: Blue gradient (#3B82F6 to #1E40AF)
- Secondary colors: Gray scale for neutral elements
- Success: Green (#10B981)
- Warning: Amber (#F59E0B)
- Error: Red (#EF4444)
- Dark mode support for future enhancement
- Design : use constant design for all pages, forms and buttons.
### 4. Navigation and Layout System
#### Dashboard Layout Structure
```mermaid
graph LR
A[App Shell] --> B[Sidebar Navigation]
A --> C[Main Content Area]
A --> D[Header Bar]
B --> E[Logo Area]
B --> F[Navigation Menu]
B --> G[User Profile]
C --> H[Page Content]
C --> I[Breadcrumbs]
D --> J[Mobile Menu Toggle]
D --> K[User Actions]
```
#### Sidebar Navigation Features
- Collapsible sidebar with full/icon-only modes
- Mobile-responsive hamburger menu
- Active state indicators
- Smooth animations and transitions
- Persistent state across page navigation
### 5. Form and Data Management
#### Form Validation Strategy
- Client-side validation using HTML5 and custom validators
- Server-side validation with Zod schemas
- Real-time validation feedback
- Internationalized error messages in Arabic
#### Data Table Components
- Sortable columns with Arabic text support
- Pagination with RTL navigation
- Search and filtering capabilities
- Responsive table design with horizontal scrolling
- Bulk actions for data management
## Data Models
### User Model
```typescript
interface User {
id: number;
name: string;
username: string;
email: string;
password: string; // hashed
status: 'active' | 'inactive';
authLevel: 1 | 2 | 3; // 1=superadmin, 2=admin, 3=user
createdDate: Date;
editDate: Date;
}
```
### Vehicle Model
```typescript
interface Vehicle {
id: number;
plateNumber: string;
bodyType: string;
manufacturer: string;
model: string;
trim?: string;
year: number;
transmission: 'Automatic' | 'Manual';
fuel: 'Gasoline' | 'Diesel' | 'Hybrid' | 'Mild Hybrid' | 'Electric';
cylinders?: number;
engineDisplacement?: number;
useType: 'personal' | 'taxi' | 'apps' | 'loading' | 'travel';
ownerId: number;
lastVisitDate?: Date;
suggestedNextVisitDate?: Date;
createdDate: Date;
updateDate: Date;
}
```
### Maintenance Visit Model
```typescript
interface MaintenanceVisit {
id: number;
vehicleId: number;
customerId: number;
maintenanceType: string;
description: string;
cost: number;
paymentStatus: string;
kilometers: number;
visitDate: Date;
nextVisitDelay: 1 | 2 | 3 | 4; // months
createdDate: Date;
updateDate: Date;
}
```
## Error Handling
### Client-Side Error Handling
- Global error boundary for React components
- Form validation error display
- Network error handling with retry mechanisms
- User-friendly error messages in Arabic
### Server-Side Error Handling
- Centralized error handling middleware
- Database constraint violation handling
- Authentication and authorization error responses
- Logging and monitoring for production debugging
### Error Response Format
```typescript
interface ErrorResponse {
success: false;
error: {
code: string;
message: string;
details?: any;
};
}
```
## Testing Strategy
### Unit Testing
- Component testing with React Testing Library
- Business logic testing for utility functions
- Database model testing with Prisma
- Authentication flow testing
### Integration Testing
- API route testing with Remix testing utilities
- Database integration testing
- Authentication middleware testing
- Form submission and validation testing
### End-to-End Testing
- User journey testing for critical paths
- Cross-browser compatibility testing
- Mobile responsiveness testing
- RTL layout verification
### Testing Tools
- Jest for unit testing
- React Testing Library for component testing
- Playwright for E2E testing
- MSW (Mock Service Worker) for API mocking
## Security Considerations
### Authentication Security
- Password hashing using bcrypt
- Secure session management
- CSRF protection
- Rate limiting for authentication attempts
### Data Protection
- Input sanitization and validation
- SQL injection prevention through Prisma
- XSS protection through proper escaping
- Secure headers configuration
### Access Control
- Role-based access control (RBAC)
- Route-level protection
- API endpoint authorization
- Data filtering based on user permissions
## Performance Optimization
### Frontend Performance
- Code splitting with Remix route-based splitting
- Image optimization and lazy loading
- CSS optimization with Tailwind purging
- Bundle size monitoring and optimization
### Backend Performance
- Database query optimization with Prisma
- Caching strategies for frequently accessed data
- Connection pooling for database connections
- Response compression and minification
### Mobile Performance
- Touch-friendly interface design
- Optimized images for different screen densities
- Reduced JavaScript bundle size for mobile
- Progressive Web App (PWA) capabilities
## Deployment and Infrastructure
### Development Environment
- Local SQLite database for development
- Hot module replacement with Vite
- TypeScript compilation and type checking
- ESLint and Prettier for code quality
### Production Deployment
- Build optimization with Remix production build
- Database migration strategy
- Environment variable management
- Health check endpoints for monitoring
### Monitoring and Logging
- Application performance monitoring
- Error tracking and reporting
- User analytics and usage metrics
- Database performance monitoring

View File

@@ -0,0 +1,165 @@
# Requirements Document
## Introduction
This document outlines the requirements for a comprehensive car maintenance management system built with Remix, SQLite, and Prisma. The system is designed for business owners to track vehicle visits and maintenance records with a focus on Arabic language support, RTL design, and mobile-friendly responsive UI/UX.
## Requirements
### Requirement 1: Core Framework and Database Setup
**User Story:** As a business owner, I want a robust web application built on modern technologies, so that I can reliably manage my car maintenance business operations.
#### Acceptance Criteria
1. WHEN the application is deployed THEN the system SHALL use Remix as the web framework
2. WHEN data needs to be stored THEN the system SHALL use SQLite database with Prisma ORM
3. WHEN the application starts THEN the system SHALL initialize properly with all database connections established
### Requirement 2: Arabic Language and RTL Support
**User Story:** As an Arabic-speaking business owner, I want the interface to support Arabic language with proper RTL layout, so that I can use the system naturally in my native language.
#### Acceptance Criteria
1. WHEN the application loads THEN the system SHALL display all text in Arabic with RTL text direction
2. WHEN viewing any page THEN the system SHALL render Arabic fonts correctly
3. WHEN navigating the interface THEN all UI components SHALL support RTL layout orientation
4. WHEN using forms THEN input fields SHALL align properly for RTL text entry
### Requirement 3: Responsive Design and Mobile Support
**User Story:** As a business owner who works on different devices, I want a responsive interface that works seamlessly on desktop, tablet, and mobile, so that I can manage my business from anywhere.
#### Acceptance Criteria
1. WHEN accessing the application on desktop THEN the system SHALL display a full dashboard layout
2. WHEN accessing the application on tablet THEN the system SHALL adapt the layout for medium screens
3. WHEN accessing the application on mobile THEN the system SHALL provide a mobile-optimized interface
4. WHEN resizing the browser window THEN the system SHALL smoothly transition between responsive breakpoints
### Requirement 4: Dashboard Navigation System
**User Story:** As a user, I want an intuitive navigation system with collapsible sidebar, so that I can efficiently access different sections of the application.
#### Acceptance Criteria
1. WHEN viewing the dashboard THEN the system SHALL display a collapsible sidebar navigation menu
2. WHEN the sidebar is collapsed THEN the system SHALL show icon-only navigation
3. WHEN on mobile devices THEN the system SHALL provide a hamburger menu for navigation
4. WHEN navigating between sections THEN the system SHALL provide smooth transitions
5. WHEN the sidebar state changes THEN the system SHALL maintain the state across page navigation
### Requirement 5: User Authentication System
**User Story:** As a system administrator, I want a secure custom authentication system, so that I can control access to the application and protect sensitive business data.
#### Acceptance Criteria
1. WHEN a user attempts to sign in THEN the system SHALL authenticate using username or email with password
2. WHEN storing passwords THEN the system SHALL hash passwords securely
3. WHEN managing sessions THEN the system SHALL implement proper session management
4. WHEN authentication fails THEN the system SHALL display appropriate error messages
5. WHEN authentication succeeds THEN the system SHALL redirect to the appropriate dashboard
### Requirement 6: User Management and Access Control
**User Story:** As a superadmin, I want to manage user accounts with different access levels, so that I can control who has access to what features in the system.
#### Acceptance Criteria
1. WHEN creating users THEN the system SHALL support three auth levels: 1=superadmin, 2=admin, 3=user
2. WHEN an admin views users THEN the system SHALL hide superadmin accounts from admin users
3. WHEN a superadmin views users THEN the system SHALL display all user accounts
4. WHEN managing user status THEN the system SHALL support "active" and "inactive" status
5. WHEN no admin users exist THEN the system SHALL allow access to the signup page
6. WHEN admin users exist THEN the system SHALL restrict access to the signup page
### Requirement 7: Customer Management
**User Story:** As a business owner, I want to manage customer information, so that I can maintain records of vehicle owners and their contact details.
#### Acceptance Criteria
1. WHEN adding a customer THEN the system SHALL store customer contact information
2. WHEN viewing customers THEN the system SHALL display a searchable list of all customers
3. WHEN editing customer information THEN the system SHALL update records with proper validation
4. WHEN deleting a customer THEN the system SHALL handle associated vehicle relationships appropriately
5. WHEN creating a customer THEN the system SHALL validate required fields and uniqueness constraints
### Requirement 7.1: Enhanced Customer Details View
**User Story:** As a business owner, I want a comprehensive customer details view that shows all related information in one place, so that I can quickly access customer vehicles, maintenance history, and take relevant actions.
#### Acceptance Criteria
1. WHEN viewing a customer's details THEN the system SHALL display a redesigned "المعلومات الأساسية" (Basic Information) section with improved layout
2. WHEN viewing customer details THEN the system SHALL show all vehicles owned by the customer
3. WHEN clicking on a vehicle in the customer details THEN the system SHALL navigate to the vehicles page filtered by that specific vehicle's plate number
4. WHEN viewing customer details THEN the system SHALL provide a "Show All Vehicles" button that opens the vehicles page filtered by the customer
5. WHEN viewing customer details THEN the system SHALL display the latest 3 maintenance visits for the customer
6. WHEN viewing customer details THEN the system SHALL provide a button to view all maintenance visits filtered by the customer
7. WHEN no maintenance visits exist THEN the system SHALL display an appropriate message encouraging the first visit
8. WHEN viewing vehicle information THEN the system SHALL show key details like plate number, manufacturer, model, and year
9. WHEN viewing maintenance visit information THEN the system SHALL show visit date, maintenance type, cost, and payment status
### Requirement 8: Vehicle Management
**User Story:** As a business owner, I want to manage detailed vehicle information, so that I can track each vehicle's specifications and maintenance history.
#### Acceptance Criteria
1. WHEN adding a vehicle THEN the system SHALL store plate number, body type, manufacturer, model, year, transmission, fuel type, and use type
2. WHEN entering vehicle details THEN the system SHALL provide dropdown lists for body type, transmission, and fuel type options
3. WHEN saving a vehicle THEN the system SHALL ensure plate number uniqueness
4. WHEN linking vehicles THEN the system SHALL associate each vehicle with a customer owner
5. WHEN viewing vehicles THEN the system SHALL display last visit date and suggested next visit date
6. WHEN editing vehicle information THEN the system SHALL update records with proper validation
### Requirement 9: Maintenance Visit Management
**User Story:** As a service technician, I want to record maintenance visits with detailed information, so that I can track what work was performed and when.
#### Acceptance Criteria
1. WHEN creating a maintenance visit THEN the system SHALL link the visit to a specific vehicle and customer
2. WHEN recording visit details THEN the system SHALL store maintenance type, description, cost, and kilometers
3. WHEN completing a visit THEN the system SHALL update the vehicle's last visit date to the current date
4. WHEN setting next visit THEN the system SHALL calculate suggested next visit date based on selected delay period
5. WHEN selecting visit delay THEN the system SHALL provide options for 1, 2, 3, and 4 months
6. WHEN saving a visit THEN the system SHALL generate corresponding income records
### Requirement 10: Financial Management
**User Story:** As a business owner, I want to track expenses and income, so that I can monitor the financial performance of my maintenance business.
#### Acceptance Criteria
1. WHEN recording expenses THEN the system SHALL store expense details with proper categorization
2. WHEN a maintenance visit is completed THEN the system SHALL automatically generate income records
3. WHEN viewing financial data THEN the system SHALL provide summaries of income and expenses
4. WHEN managing financial records THEN the system SHALL support full CRUD operations
5. WHEN calculating totals THEN the system SHALL provide accurate financial reporting
### Requirement 11: Data Validation and Error Handling
**User Story:** As a user, I want proper validation and clear error messages, so that I can understand and correct any input errors quickly.
#### Acceptance Criteria
1. WHEN submitting forms THEN the system SHALL validate all required fields
2. WHEN validation fails THEN the system SHALL display clear, localized error messages
3. WHEN database operations fail THEN the system SHALL handle errors gracefully
4. WHEN unique constraints are violated THEN the system SHALL provide specific error feedback
5. WHEN network errors occur THEN the system SHALL provide appropriate user feedback
### Requirement 12: Database Seeding and Initial Setup
**User Story:** As a system administrator, I want the system to initialize with essential data, so that I can start using the application immediately after deployment.
#### Acceptance Criteria
1. WHEN the database is first created THEN the system SHALL seed one superadmin account
2. WHEN seeding data THEN the system SHALL create essential system configuration data
3. WHEN running seed scripts THEN the system SHALL handle existing data appropriately
4. WHEN initializing THEN the system SHALL set up proper database relationships and constraints

View File

@@ -0,0 +1,201 @@
# Implementation Plan
- [x] 1. Set up project foundation and database schema
- Configure Prisma schema with all required models and relationships
- Set up database migrations and seed data
- Configure TypeScript types for all models
- _Requirements: 1.1, 1.2, 1.3, 12.1, 12.2, 12.3, 12.4_
- [x] 2. Implement authentication system foundation
- Create session management utilities and middleware
- Implement password hashing and validation functions
- Create authentication helper functions and types
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5_
- [x] 3. Create authentication routes and forms
- Implement signin route with username/email login support
- Create signup route with conditional access control
- Build authentication form components with validation
- Implement proper error handling and user feedback
- _Requirements: 5.1, 5.4, 5.5, 6.5, 6.6, 11.1, 11.2, 11.4_
- [x] 4. Set up RTL layout and Arabic language support
- Configure Tailwind CSS for RTL support and Arabic fonts
- Create base layout components with RTL orientation
- Implement responsive design utilities and breakpoints
- Test Arabic text rendering and layout behavior
- _Requirements: 2.1, 2.2, 2.3, 2.4, 3.1, 3.2, 3.3, 3.4_
- [x] 5. Build dashboard layout and navigation system
- Create responsive sidebar navigation component
- Implement collapsible sidebar with icon-only mode
- Build mobile hamburger menu and responsive behavior
- Add smooth transitions and state persistence
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5_
- [x] 6. Implement route protection and access control
- Create route protection middleware for different auth levels
- Implement role-based access control logic
- Add automatic redirection for unauthorized access
- Test access control for all user types
- _Requirements: 6.1, 6.2, 6.3, 6.4_
- [x] 7. Create user management system
- Build user listing page with role-based filtering
- Implement user creation, editing, and status management
- Create user deletion functionality with proper validation
- Add search and filtering capabilities for user management
- _Requirements: 6.1, 6.2, 6.3, 6.4, 7.1, 7.2, 7.3, 7.4, 7.5_
- [x] 8. Implement customer management CRUD operations
- Create customer model and database operations
- Build customer listing page with search functionality
- Implement customer creation and editing forms
- Add customer deletion with relationship handling
- _Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 11.1, 11.2, 11.3_
- [x] 9. Build vehicle management system
- Create vehicle model with all required fields and relationships
- Implement vehicle registration form with dropdown selections
- Build vehicle listing and search functionality
- Add vehicle editing and deletion capabilities
- _Requirements: 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 11.1, 11.2, 11.4_
- [x] 10. Implement maintenance visit management
- Create maintenance visit model and database operations
- Build visit registration form with vehicle and customer linking
- Implement visit delay selection and next visit date calculation
- Add visit listing, editing, and deletion functionality
- _Requirements: 9.1, 9.2, 9.3, 9.4, 9.5, 9.6_
- [x] 11. Create financial management system
- Implement expense management CRUD operations
- Build automatic income generation from maintenance visits
- Create financial reporting and summary views
- Add expense and income listing with filtering
- _Requirements: 10.1, 10.2, 10.3, 10.4, 10.5_
- [x] 12. Build reusable form components and validation
- Create reusable form input components with RTL support
- Implement client-side and server-side validation
- Build data table components with Arabic text support
- Add pagination, sorting, and filtering utilities
- _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5_
- [x] 13. Enhance customer details view with comprehensive information display (note that i do not use dynamic page $... i use popup forms for update and view details)
- Redesign the "المعلومات الأساسية" (Basic Information) section with improved layout
- Display all customer vehicles with clickable navigation to filtered vehicle pages
- Add "Show All Vehicles" button that opens vehicles page filtered by customer
- Show latest 3 maintenance visits with key information (date, type, cost, payment status)
- Add "View All Visits" button that opens maintenance visits page filtered by customer
- Implement proper navigation with URL parameters for filtering
- Add responsive design for mobile and tablet views
- _Requirements: 7.1.1, 7.1.2, 7.1.3, 7.1.4, 7.1.5, 7.1.6, 7.1.7, 7.1.8, 7.1.9_
- [ ] 14. Implement comprehensive error handling
- Create global error boundary components
- Implement centralized error handling middleware
- Add proper error logging and user feedback
- Create Arabic error message translations
- _Requirements: 11.1, 11.2, 11.3, 11.4, 11.5_
- [ ] 15. Add responsive design and mobile optimization
- Optimize all components for mobile devices
- Test and refine responsive breakpoints
- Implement touch-friendly interactions
- Verify RTL layout on all screen sizes
- _Requirements: 3.1, 3.2, 3.3, 3.4, 4.3, 4.4_
- [ ] 16. Create database seeding and initial setup
- Implement database seed script with superadmin account
- Create essential system data initialization
- Add proper error handling for existing data
- Test database initialization and migration process
- _Requirements: 12.1, 12.2, 12.3, 12.4_
- [ ] 17. Implement comprehensive testing suite
- Write unit tests for all business logic functions
- Create integration tests for API routes and database operations
- Add component tests for UI components
- Implement end-to-end tests for critical user journeys
- _Requirements: All requirements validation through testing_
- [ ] 18. Final integration and system testing
- Test complete user workflows from authentication to data management
- Verify all CRUD operations work correctly with proper validation
- Test role-based access control across all features
- Validate Arabic language support and RTL layout throughout the system
- _Requirements: All requirements integration testing_

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"kiroAgent.configureMCP": "Disabled"
}

View File

@@ -0,0 +1,117 @@
# Maintenance Type Migration Summary
## Overview
Updated the maintenance system to use a separate `MaintenanceType` table instead of storing maintenance types as simple strings. This provides better data consistency, allows for better management of maintenance types, and enables features like descriptions and active/inactive status.
## Database Changes
### New Table: `maintenance_types`
- `id` (Primary Key)
- `name` (Unique, required)
- `description` (Optional)
- `isActive` (Boolean, default true)
- `createdDate` (Timestamp)
- `updateDate` (Timestamp)
### Updated Table: `maintenance_visits`
- Replaced `maintenanceType` (string) with `maintenanceTypeId` (foreign key)
- Added foreign key constraint to `maintenance_types` table
## Code Changes
### 1. Schema Updates
- **File**: `prisma/schema.prisma`
- Added `MaintenanceType` model
- Updated `MaintenanceVisit` model to use `maintenanceTypeId`
- Added relationship between models
### 2. Type Definitions
- **File**: `app/types/database.ts`
- Added `MaintenanceType` export
- Added `CreateMaintenanceTypeData` and `UpdateMaintenanceTypeData` interfaces
- Updated `CreateMaintenanceVisitData` and `UpdateMaintenanceVisitData` to use `maintenanceTypeId`
- Updated `MaintenanceVisitWithRelations` to include `maintenanceType` relationship
### 3. New Server Functions
- **File**: `app/lib/maintenance-type-management.server.ts` (NEW)
- `getMaintenanceTypes()` - Get all maintenance types
- `getMaintenanceTypesForSelect()` - Get maintenance types for dropdowns
- `getMaintenanceTypeById()` - Get single maintenance type
- `createMaintenanceType()` - Create new maintenance type
- `updateMaintenanceType()` - Update existing maintenance type
- `deleteMaintenanceType()` - Soft delete maintenance type
- `toggleMaintenanceTypeStatus()` - Toggle active/inactive status
- `getMaintenanceTypeStats()` - Get statistics for maintenance type
### 4. Updated Server Functions
- **File**: `app/lib/maintenance-visit-management.server.ts`
- Updated all functions to include `maintenanceType` relationship in queries
- Updated search functionality to search within maintenance type names
- Updated create/update functions to use `maintenanceTypeId`
### 5. Validation Updates
- **File**: `app/lib/validation.ts`
- Updated `validateMaintenanceVisit()` to validate `maintenanceTypeId` instead of `maintenanceType`
### 6. Route Updates
- **File**: `app/routes/maintenance-visits.tsx`
- Added import for `getMaintenanceTypesForSelect`
- Updated loader to fetch maintenance types
- Updated action handlers to work with `maintenanceTypeId`
- Updated form data processing
### 7. Component Updates
- **File**: `app/components/maintenance-visits/MaintenanceVisitForm.tsx`
- Updated to accept `maintenanceTypes` prop
- Changed maintenance type field from text input to select dropdown
- Updated form field name from `maintenanceType` to `maintenanceTypeId`
- **File**: `app/components/maintenance-visits/MaintenanceVisitDetailsView.tsx`
- Updated to display `visit.maintenanceType.name` instead of `visit.maintenanceType`
- Added display of maintenance type description if available
- **File**: `app/components/vehicles/VehicleDetailsView.tsx`
- Updated to display maintenance type name with fallback for undefined types
### 8. Seed Data Updates
- **File**: `prisma/seed.ts`
- Added creation of default maintenance types
- Updated sample maintenance visits to use maintenance type IDs
## Default Maintenance Types
The system now includes these default maintenance types:
1. صيانة دورية (Periodic Maintenance)
2. تغيير زيت المحرك (Engine Oil Change)
3. إصلاح الفرامل (Brake Repair)
4. إصلاح المحرك (Engine Repair)
5. إصلاح ناقل الحركة (Transmission Repair)
6. إصلاح التكييف (AC Repair)
7. إصلاح الإطارات (Tire Repair)
8. إصلاح الكهرباء (Electrical Repair)
9. فحص شامل (Comprehensive Inspection)
10. أخرى (Other)
## Migration Steps
1. Run the database migration: `prisma/migrations/add_maintenance_types.sql`
2. Update your database schema
3. Run the seed script to populate default maintenance types
4. Test the application to ensure all functionality works correctly
## Benefits
- **Data Consistency**: Standardized maintenance type names
- **Extensibility**: Easy to add new maintenance types without code changes
- **Management**: Can activate/deactivate maintenance types
- **Descriptions**: Each maintenance type can have a detailed description
- **Statistics**: Can track usage and revenue by maintenance type
- **Validation**: Better form validation with predefined options
## Breaking Changes
- API endpoints now expect `maintenanceTypeId` instead of `maintenanceType`
- Database queries need to include the maintenance type relationship
- Forms now use dropdown selection instead of free text input
## Future Enhancements
- Admin interface for managing maintenance types
- Maintenance type categories/grouping
- Pricing suggestions per maintenance type
- Maintenance type templates with common descriptions

250
ROUTE_PROTECTION.md Normal file
View File

@@ -0,0 +1,250 @@
# Route Protection and Access Control Implementation
This document describes the comprehensive route protection and access control system implemented for the car maintenance management system.
## Overview
The route protection system provides:
- **Authentication middleware** for all protected routes
- **Role-based access control** (RBAC) with three levels: Superadmin, Admin, User
- **Automatic redirection** for unauthorized access attempts
- **Session validation** and user status checking
- **Route-specific protection** functions
- **Permission checking** utilities
- **Redirect authenticated users** from auth pages (signin/signup)
## Authentication Levels
The system uses a hierarchical authentication system where lower numbers indicate higher privileges:
| Level | Name | Value | Description |
|-------|------------|-------|---------------------------------------|
| 1 | Superadmin | 1 | Full system access, can manage admins |
| 2 | Admin | 2 | Can manage users and business data |
| 3 | User | 3 | Basic access to operational features |
## Route Protection Matrix
| Route | Auth Required | Min Level | Superadmin | Admin | User |
|--------------------------|---------------|-----------|------------|-------|------|
| `/dashboard` | ✓ | User (3) | ✓ | ✓ | ✓ |
| `/users` | ✓ | Admin (2) | ✓ | ✓ | ✗ |
| `/customers` | ✓ | User (3) | ✓ | ✓ | ✓ |
| `/vehicles` | ✓ | User (3) | ✓ | ✓ | ✓ |
| `/maintenance-visits` | ✓ | User (3) | ✓ | ✓ | ✓ |
| `/financial` | ✓ | Admin (2) | ✓ | ✓ | ✗ |
| `/admin/enable-signup` | ✓ | Super (1) | ✓ | ✗ | ✗ |
| `/signin` | ✗ | None | ✓* | ✓* | ✓* |
| `/signup` | ✗ | None | ✓* | ✓* | ✓* |
*\* Authenticated users are redirected away from auth pages*
## Implementation Details
### Core Middleware Functions
#### `requireAuthentication(request, options?)`
- Ensures user is logged in and active
- Options: `allowInactive`, `redirectTo`
- Redirects to `/signin` if not authenticated
- Checks user status (active/inactive)
#### `requireAdmin(request, options?)`
- Requires Admin level (2) or higher
- Calls `requireAuthLevel` with `AUTH_LEVELS.ADMIN`
#### `requireSuperAdmin(request, options?)`
- Requires Superadmin level (1)
- Calls `requireAuthLevel` with `AUTH_LEVELS.SUPERADMIN`
#### `redirectIfAuthenticated(request, redirectTo?)`
- Redirects authenticated users away from auth pages
- Default redirect: `/dashboard`
- Only redirects active users
### Route-Specific Protection Functions
#### `protectUserManagementRoute(request)`
- Used by `/users` route
- Requires Admin level or higher
- Additional business logic validation
#### `protectFinancialRoute(request)`
- Used by `/financial` route
- Requires Admin level or higher
#### `protectCustomerRoute(request)`
- Used by `/customers` route
- Requires any authenticated user
#### `protectVehicleRoute(request)`
- Used by `/vehicles` route
- Requires any authenticated user
#### `protectMaintenanceRoute(request)`
- Used by `/maintenance-visits` route
- Requires any authenticated user
### Permission Checking
#### `checkPermission(user, permission)`
Checks specific permissions for a user:
| Permission | Superadmin | Admin | User |
|-------------------|------------|-------|------|
| `view_all_users` | ✓ | ✗ | ✗ |
| `create_users` | ✓ | ✓ | ✗ |
| `manage_finances` | ✓ | ✓ | ✗ |
| `view_reports` | ✓ | ✓ | ✗ |
### Session Validation
#### `validateSession(request)`
Returns session validation result:
```typescript
{
isValid: boolean;
user: SafeUser | null;
error?: 'no_user' | 'inactive_user' | 'session_error';
}
```
### Error Handling
#### `createUnauthorizedResponse(message?)`
- Creates 403 Forbidden response
- Default message in Arabic
- Custom message support
## Usage Examples
### Protecting a Route
```typescript
// In route loader
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireAdmin(request);
return json({ user });
}
```
### Custom Protection
```typescript
// In route loader with custom options
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireAuthentication(request, {
redirectTo: "/custom-login",
allowInactive: false
});
return json({ user });
}
```
### Permission Checking
```typescript
// In component or route
const canManageUsers = checkPermission(user, 'create_users');
if (canManageUsers) {
// Show user management UI
}
```
### Conditional Access Control
```typescript
// Signup route with conditional access
export async function loader({ request }: LoaderFunctionArgs) {
await redirectIfAuthenticated(request);
const signupAllowed = await isSignupAllowed();
if (!signupAllowed && !adminOverride) {
return redirect("/signin?error=signup_disabled");
}
return json({ signupAllowed });
}
```
## Security Features
### Authentication Security
- ✓ Secure session management with httpOnly cookies
- ✓ Password hashing with bcrypt
- ✓ Session expiration handling
- ✓ CSRF protection through Remix forms
### Authorization Security
- ✓ Role-based access control (RBAC)
- ✓ Route-level protection
- ✓ API endpoint authorization
- ✓ User status validation (active/inactive)
### Error Handling
- ✓ Graceful error responses
- ✓ Proper HTTP status codes
- ✓ Localized error messages (Arabic)
- ✓ Secure error information disclosure
## Testing
The route protection system includes comprehensive tests:
### Integration Tests
- Permission checking logic
- Auth level hierarchy validation
- Error response creation
- User status validation
### Route Tests
- Authentication requirement verification
- Role-based access control
- Redirection behavior
- Error handling
## Requirements Compliance
This implementation satisfies the following requirements:
### Requirement 6.1: User Authentication Levels
✓ Supports three auth levels: 1=superadmin, 2=admin, 3=user
### Requirement 6.2: Admin User Visibility
✓ Admins cannot see superadmin accounts
✓ Superadmins can see all user accounts
### Requirement 6.3: User Status Management
✓ Supports "active" and "inactive" status
✓ Inactive users are properly handled
### Requirement 6.4: Conditional Signup Access
✓ Signup restricted when admin users exist
✓ Signup allowed when no admin users exist
## Future Enhancements
Potential improvements for the route protection system:
1. **Rate Limiting**: Add rate limiting for authentication attempts
2. **Audit Logging**: Log access attempts and authorization failures
3. **Session Management**: Advanced session management with refresh tokens
4. **Multi-Factor Authentication**: Add MFA support for admin accounts
5. **API Key Authentication**: Support for API access with keys
6. **Permission Caching**: Cache permission checks for performance
7. **Dynamic Permissions**: Database-driven permission system
## Troubleshooting
### Common Issues
1. **Redirect Loops**: Check for circular redirects in auth logic
2. **Session Expiry**: Ensure proper session renewal
3. **Permission Denied**: Verify user auth level and status
4. **Inactive Users**: Check user status in database
### Debug Information
Enable debug logging by setting environment variables:
```bash
DEBUG=auth:*
LOG_LEVEL=debug
```
This will provide detailed information about authentication and authorization decisions.

View File

@@ -0,0 +1,233 @@
# 🔧 Settings System Implementation Summary
## ✅ What Has Been Completed
### 1. **Database Schema & Migration**
- ✅ Added `Settings` model to Prisma schema
- ✅ Created migration SQL file (`prisma/migrations/add_settings.sql`)
- ✅ Created settings seeding script (`prisma/settingsSeed.ts`)
- ✅ Database schema updated with `npx prisma db push`
### 2. **Server-Side Settings Management**
- ✅ Created `app/lib/settings-management.server.ts` with comprehensive settings API
- ✅ Implemented settings CRUD operations
- ✅ Added settings formatter utilities
- ✅ Error handling for missing settings table
- ✅ Default settings fallback system
### 3. **React Context & Hooks**
- ✅ Created `app/contexts/SettingsContext.tsx` for global settings access
- ✅ Implemented formatting hooks (`useSettings`, `useFormatters`)
- ✅ Integrated settings provider in root layout
### 4. **Settings Management UI**
- ✅ Created comprehensive settings page (`app/routes/settings.tsx`)
- ✅ Real-time preview of formatting changes
- ✅ Admin-only access control
- ✅ Form validation and error handling
- ✅ Settings reset functionality
### 5. **Component Integration**
- ✅ Updated `MaintenanceVisitDetailsView` with settings formatting
- ✅ Updated `VehicleDetailsView` with settings formatting
- ✅ Updated `CustomerDetailsView` with settings formatting
- ✅ Added settings link to sidebar navigation
### 6. **Documentation**
- ✅ Created comprehensive documentation (`SETTINGS_SYSTEM_README.md`)
- ✅ Implementation guide and troubleshooting
- ✅ API reference and usage examples
## 🔄 Next Steps to Complete Setup
### Step 1: Generate Prisma Client
The current issue is that Prisma Client needs to be regenerated to include the new Settings model.
```bash
# Stop the dev server first (Ctrl+C)
npx prisma generate
```
### Step 2: Seed Default Settings
```bash
npx tsx prisma/settingsSeed.ts
```
### Step 3: Restart Development Server
```bash
npm run dev
```
### Step 4: Test Settings System
1. Navigate to `http://localhost:5173/login`
2. Login with admin credentials
3. Go to Settings page (`/settings`)
4. Test different format configurations
5. Verify changes appear across all pages
## 🎯 Features Implemented
### **Date Format Configuration**
- **Arabic (ar-SA)**: ٩/١١/٢٠٢٥ (Arabic numerals)
- **English (en-US)**: 11/9/2025 (Western numerals)
- Applied to: Visit dates, creation dates, update dates
### **Currency Configuration**
- **Supported**: JOD (د.أ), USD ($), EUR (€), SAR (ر.س), AED (د.إ)
- **Custom symbols**: User-configurable currency symbols
- Applied to: All cost displays, financial reports
### **Number Format Configuration**
- **Arabic (ar-SA)**: ١٬٢٣٤٫٥٦ (Arabic numerals with Arabic separators)
- **English (en-US)**: 1,234.56 (Western numerals with Western separators)
- Applied to: Kilometers, costs, all numeric displays
## 📁 Files Created/Modified
### New Files:
```
app/lib/settings-management.server.ts
app/contexts/SettingsContext.tsx
app/routes/settings.tsx
prisma/settingsSeed.ts
prisma/migrations/add_settings.sql
SETTINGS_SYSTEM_README.md
SETTINGS_IMPLEMENTATION_SUMMARY.md
```
### Modified Files:
```
prisma/schema.prisma (Added Settings model)
app/root.tsx (Added settings provider)
app/components/layout/Sidebar.tsx (Added settings link)
app/components/maintenance-visits/MaintenanceVisitDetailsView.tsx
app/components/vehicles/VehicleDetailsView.tsx
app/components/customers/CustomerDetailsView.tsx
app/routes/maintenance-visits.tsx (Added settings import)
```
## 🔧 Settings API Reference
### Server Functions:
- `getAppSettings()`: Get all settings as typed object
- `updateSettings(settings)`: Update multiple settings
- `getSetting(key)`: Get specific setting value
- `updateSetting(key, value)`: Update single setting
- `initializeDefaultSettings()`: Create default settings
### React Hooks:
- `useSettings()`: Access settings and formatters
- `useFormatters(settings)`: Create formatters without context
### Formatting Functions:
- `formatCurrency(amount)`: Format currency with symbol
- `formatDate(date)`: Format date according to locale
- `formatNumber(number)`: Format numbers according to locale
- `formatDateTime(date)`: Format date and time
## 🎨 Usage Examples
### In Components:
```typescript
import { useSettings } from '~/contexts/SettingsContext';
function MyComponent() {
const { formatCurrency, formatDate, formatNumber } = useSettings();
return (
<div>
<p>Cost: {formatCurrency(250.75)}</p>
<p>Date: {formatDate(new Date())}</p>
<p>Kilometers: {formatNumber(45000)} كم</p>
</div>
);
}
```
### In Loaders:
```typescript
import { getAppSettings, createFormatter } from '~/lib/settings-management.server';
export async function loader() {
const settings = await getAppSettings();
const formatter = await createFormatter();
return json({
formattedCost: formatter.formatCurrency(1234.56),
settings
});
}
```
## 🚀 Benefits Achieved
### **Centralized Configuration**
- Single source of truth for all formatting
- Consistent formatting across entire application
- Easy to change formats globally
### **Localization Support**
- Arabic and English number formats
- Proper RTL/LTR date formatting
- Cultural currency display preferences
### **Admin Control**
- Real-time format changes
- Preview before applying
- Reset to defaults functionality
### **Developer Experience**
- Type-safe settings API
- Easy-to-use React hooks
- Comprehensive error handling
### **Performance**
- Settings cached in React context
- Minimal database queries
- Efficient formatting utilities
## 🔍 Troubleshooting
### Common Issues:
1. **"Cannot read properties of undefined (reading 'settings')"**
- Solution: Run `npx prisma generate` to update Prisma Client
2. **Settings not loading**
- Solution: Run `npx tsx prisma/settingsSeed.ts` to seed defaults
3. **Formatting not applied**
- Solution: Ensure components use `useSettings()` hook
4. **Settings page not accessible**
- Solution: Login with admin account (authLevel 2 or higher)
## 🎉 Success Criteria
When setup is complete, you should be able to:
1. ✅ Access settings page at `/settings` (admin only)
2. ✅ Change date format and see immediate preview
3. ✅ Switch currency and symbol
4. ✅ Toggle number format between Arabic/English
5. ✅ See formatting changes across all pages:
- Maintenance visit costs and dates
- Vehicle information and kilometers
- Customer creation dates
- All numeric displays
## 📈 Future Enhancements
The system is designed to be extensible for:
- Time zone configuration
- Additional currency support
- Custom date format patterns
- Language switching (full Arabic/English UI)
- User-specific settings
- Settings import/export
---
**Status**: Implementation Complete - Requires Prisma Client Regeneration
**Next Action**: Run `npx prisma generate` and seed settings
**Estimated Time**: 2-3 minutes to complete setup

298
SETTINGS_SYSTEM_README.md Normal file
View File

@@ -0,0 +1,298 @@
# 🔧 Settings System Documentation
## Overview
The Settings System provides centralized configuration management for the Car Maintenance Management System, allowing administrators to customize date formats, currency, and number formatting across the entire application.
## 🎯 Features
### 1. **Date Format Configuration**
- **Arabic (ar-SA)**: ٢٠٢٥/١١/٩ (Arabic numerals)
- **English (en-US)**: 11/9/2025 (Western numerals)
- **Display Patterns**: dd/MM/yyyy, MM/dd/yyyy, yyyy-MM-dd
### 2. **Currency Configuration**
- **Supported Currencies**: JOD, USD, EUR, SAR, AED
- **Custom Currency Symbols**: د.أ, $, €, ر.س, د.إ
- **Automatic Formatting**: 1,234.56 د.أ
### 3. **Number Format Configuration**
- **Arabic (ar-SA)**: ١٬٢٣٤٫٥٦ (Arabic numerals with Arabic separators)
- **English (en-US)**: 1,234.56 (Western numerals with Western separators)
- **Applied to**: Costs, kilometers, all numeric displays
## 🏗️ Architecture
### Database Schema
```sql
CREATE TABLE "settings" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"key" TEXT NOT NULL UNIQUE,
"value" TEXT NOT NULL,
"description" TEXT,
"createdDate" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updateDate" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```
### Settings Keys
| Key | Description | Default Value | Options |
|-----|-------------|---------------|---------|
| `dateFormat` | Date format locale | `ar-SA` | `ar-SA`, `en-US` |
| `currency` | Currency code | `JOD` | `JOD`, `USD`, `EUR`, `SAR`, `AED` |
| `numberFormat` | Number format locale | `ar-SA` | `ar-SA`, `en-US` |
| `currencySymbol` | Currency symbol | `د.أ` | Any string |
| `dateDisplayFormat` | Date pattern | `dd/MM/yyyy` | Various patterns |
## 📁 File Structure
```
app/
├── lib/
│ └── settings-management.server.ts # Server-side settings management
├── contexts/
│ └── SettingsContext.tsx # React context for settings
├── routes/
│ └── settings.tsx # Settings management page
├── components/
│ ├── layout/
│ │ └── Sidebar.tsx # Updated with settings link
│ ├── maintenance-visits/
│ │ └── MaintenanceVisitDetailsView.tsx # Uses settings formatting
│ ├── vehicles/
│ │ └── VehicleDetailsView.tsx # Uses settings formatting
│ └── customers/
│ └── CustomerDetailsView.tsx # Uses settings formatting
└── root.tsx # Settings provider integration
prisma/
├── schema.prisma # Settings model
├── settingsSeed.ts # Settings seeding script
└── migrations/
└── add_settings.sql # Settings table migration
```
## 🔧 Implementation Details
### 1. Settings Management Server (`settings-management.server.ts`)
#### Key Functions:
- `getAppSettings()`: Retrieves all settings as typed object
- `updateSettings()`: Updates multiple settings atomically
- `createFormatter()`: Creates formatter instance with current settings
- `SettingsFormatter`: Utility class for consistent formatting
#### Usage Example:
```typescript
import { getAppSettings, createFormatter } from '~/lib/settings-management.server';
// In loader
const settings = await getAppSettings();
// Create formatter
const formatter = await createFormatter();
const formattedCurrency = formatter.formatCurrency(1234.56);
const formattedDate = formatter.formatDate(new Date());
```
### 2. Settings Context (`SettingsContext.tsx`)
#### Provides:
- `settings`: Current settings object
- `formatNumber(value)`: Format numbers according to settings
- `formatCurrency(value)`: Format currency with symbol
- `formatDate(date)`: Format dates according to settings
- `formatDateTime(date)`: Format date and time
#### Usage Example:
```typescript
import { useSettings } from '~/contexts/SettingsContext';
function MyComponent() {
const { formatCurrency, formatDate, formatNumber } = useSettings();
return (
<div>
<p>Cost: {formatCurrency(250.75)}</p>
<p>Date: {formatDate(new Date())}</p>
<p>Kilometers: {formatNumber(45000)} كم</p>
</div>
);
}
```
### 3. Root Integration (`root.tsx`)
The root layout loads settings and provides them to all components:
```typescript
export async function loader({ request }: LoaderFunctionArgs) {
await initializeDefaultSettings();
const settings = await getAppSettings();
return json({ settings });
}
export default function App() {
const { settings } = useLoaderData<typeof loader>();
return (
<SettingsProvider settings={settings}>
<Outlet />
</SettingsProvider>
);
}
```
## 🎨 Settings Page Features
### Admin Interface (`/settings`)
- **Real-time Preview**: See formatting changes immediately
- **Validation**: Ensures valid locale and currency codes
- **Reset Functionality**: Restore default settings
- **Visual Examples**: Shows how settings affect different data types
### Security
- **Admin Only**: Requires authentication level 2 (Admin) or higher
- **Input Validation**: Validates all setting values
- **Error Handling**: Graceful fallback to defaults on errors
## 🌍 Localization Support
### Arabic (ar-SA)
- **Numbers**: ١٬٢٣٤٫٥٦ (Arabic-Indic digits)
- **Dates**: ٩/١١/٢٠٢٥ (Arabic calendar format)
- **Currency**: ١٬٢٣٤٫٥٦ د.أ
### English (en-US)
- **Numbers**: 1,234.56 (Western digits)
- **Dates**: 11/9/2025 (Gregorian format)
- **Currency**: 1,234.56 JOD
## 🔄 Migration Guide
### From Hardcoded Formatting
Replace hardcoded formatting:
```typescript
// Before
{visit.cost.toLocaleString('ar-SA')} د.أ
{new Date(visit.date).toLocaleDateString('ar-SA')}
{visit.kilometers.toLocaleString('ar-SA')} كم
// After
{formatCurrency(visit.cost)}
{formatDate(visit.date)}
{formatNumber(visit.kilometers)} كم
```
### Adding New Components
1. Import settings context: `import { useSettings } from '~/contexts/SettingsContext';`
2. Use formatting functions: `const { formatCurrency, formatDate, formatNumber } = useSettings();`
3. Apply formatting: `{formatCurrency(amount)}` instead of hardcoded formatting
## 🧪 Testing
### Manual Testing
1. Navigate to `/settings` (admin required)
2. Change date format from Arabic to English
3. Verify changes appear across all pages:
- Maintenance visits
- Vehicle details
- Customer information
- Financial reports
### Automated Testing
```typescript
// Test settings formatting
import { SettingsFormatter } from '~/lib/settings-management.server';
const arabicSettings = {
dateFormat: 'ar-SA' as const,
numberFormat: 'ar-SA' as const,
currency: 'JOD',
currencySymbol: 'د.أ'
};
const formatter = new SettingsFormatter(arabicSettings);
expect(formatter.formatCurrency(1234.56)).toBe('١٬٢٣٤٫٥٦ د.أ');
```
## 🚀 Performance Considerations
### Caching
- Settings are loaded once at application startup
- Context provides settings to all components without re-fetching
- Database queries are minimized through upsert operations
### Memory Usage
- Settings object is lightweight (< 1KB)
- Formatter instances are created on-demand
- No memory leaks from event listeners
## 🔮 Future Enhancements
### Planned Features
1. **Time Zone Support**: Configure display time zones
2. **Language Switching**: Full Arabic/English interface toggle
3. **Custom Date Patterns**: User-defined date format strings
4. **Decimal Precision**: Configurable decimal places for currency
5. **Export/Import**: Settings backup and restore functionality
### Technical Improvements
1. **Settings Validation**: JSON schema validation for settings
2. **Audit Trail**: Track settings changes with timestamps
3. **Role-based Settings**: Different settings per user role
4. **Real-time Updates**: WebSocket-based settings synchronization
## 📋 Troubleshooting
### Common Issues
#### Settings Not Loading
```typescript
// Check if settings are initialized
await initializeDefaultSettings();
const settings = await getAppSettings();
console.log('Settings:', settings);
```
#### Formatting Not Applied
```typescript
// Ensure component uses settings context
import { useSettings } from '~/contexts/SettingsContext';
function MyComponent() {
const { formatCurrency } = useSettings();
// Use formatCurrency instead of hardcoded formatting
}
```
#### Database Errors
```bash
# Reset settings table
npx prisma db push --force-reset
npx tsx prisma/settingsSeed.ts
```
### Debug Mode
Enable debug logging in settings management:
```typescript
// In settings-management.server.ts
const DEBUG = process.env.NODE_ENV === 'development';
if (DEBUG) {
console.log('Settings loaded:', settings);
}
```
## 📚 Related Documentation
- [Database Schema](./prisma/schema.prisma)
- [Authentication System](./app/lib/auth-middleware.server.ts)
- [Component Architecture](./app/components/README.md)
- [Localization Guide](./LOCALIZATION.md)
---
**Last Updated**: November 9, 2025
**Version**: 1.0.0
**Maintainer**: Car MMS Development Team

144
SETTINGS_TESTING_GUIDE.md Normal file
View File

@@ -0,0 +1,144 @@
# 🧪 Settings System Testing Guide
## ✅ Setup Complete!
The settings system is now fully operational. Here's how to test it:
## 🔗 Access URLs
- **Application**: http://localhost:5174/
- **Settings Page**: http://localhost:5174/settings (Admin only)
## 🧪 Testing Steps
### 1. **Login as Admin**
```
URL: http://localhost:5174/login
Credentials: Use admin account (authLevel 2 or higher)
```
### 2. **Access Settings Page**
```
URL: http://localhost:5174/settings
Expected: Settings management interface with:
- Date format options (ar-SA / en-US)
- Currency selection (JOD, USD, EUR, SAR, AED)
- Number format options (ar-SA / en-US)
- Currency symbol input
- Real-time preview section
```
### 3. **Test Date Format Changes**
1. Change date format from Arabic to English
2. Click "حفظ الإعدادات" (Save Settings)
3. Navigate to maintenance visits, vehicles, or customers
4. Verify dates now show in English format (11/9/2025 vs ٩/١١/٢٠٢٥)
### 4. **Test Currency Changes**
1. Change currency from JOD to USD
2. Update currency symbol from "د.أ" to "$"
3. Save settings
4. Check maintenance visit costs and financial displays
5. Verify format changes from "1,234.56 د.أ" to "1,234.56 $"
### 5. **Test Number Format Changes**
1. Change number format from Arabic to English
2. Save settings
3. Check kilometer displays and numeric values
4. Verify format changes from "١٬٢٣٤" to "1,234"
### 6. **Test Real-time Preview**
- Change any setting and observe the preview section
- Should show immediate formatting changes before saving
## 📍 Where to See Changes
### **Maintenance Visits** (`/maintenance-visits`)
- Visit costs (currency formatting)
- Visit dates (date formatting)
- Kilometer readings (number formatting)
### **Vehicle Details** (`/vehicles`)
- Last visit dates
- Creation/update dates
- Maintenance history costs
### **Customer Details** (`/customers`)
- Customer creation dates
- Last update dates
- Associated vehicle information
## 🎯 Expected Behavior
### **Arabic Settings (ar-SA)**
```
Date: ٩/١١/٢٠٢٥
Currency: ١٬٢٣٤٫٥٦ د.أ
Numbers: ١٬٢٣٤٫٥٦
Kilometers: ٤٥٬٠٠٠ كم
```
### **English Settings (en-US)**
```
Date: 11/9/2025
Currency: 1,234.56 $
Numbers: 1,234.56
Kilometers: 45,000 كم
```
## 🔧 Admin Features
### **Settings Management**
- ✅ Real-time preview of changes
- ✅ Form validation
- ✅ Reset to defaults button
- ✅ Success/error messages
- ✅ Admin-only access control
### **Supported Currencies**
- **JOD** (د.أ) - Jordanian Dinar
- **USD** ($) - US Dollar
- **EUR** (€) - Euro
- **SAR** (ر.س) - Saudi Riyal
- **AED** (د.إ) - UAE Dirham
## 🚨 Troubleshooting
### **Settings Page Not Accessible**
- Ensure you're logged in as admin (authLevel 2+)
- Check URL: http://localhost:5174/settings
### **Changes Not Appearing**
- Refresh the page after saving settings
- Check browser console for errors
- Verify settings were saved (check preview section)
### **Formatting Not Applied**
- Clear browser cache
- Restart development server
- Check that components use `useSettings()` hook
## ✨ Success Indicators
When working correctly, you should see:
1. **Settings page loads** without errors
2. **Preview updates** in real-time as you change settings
3. **Save confirmation** message appears after updating
4. **Formatting changes** appear across all pages immediately
5. **Consistent formatting** throughout the application
## 🎉 Features Verified
- ✅ Centralized settings management
- ✅ Real-time formatting preview
- ✅ Admin access control
- ✅ Database persistence
- ✅ Error handling and fallbacks
- ✅ Cross-component formatting consistency
- ✅ Arabic/English localization support
---
**Status**: Ready for Testing
**Server**: http://localhost:5174/
**Settings**: http://localhost:5174/settings

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,257 @@
import { ReactNode, useState, useEffect } from 'react';
import { Link, useLocation } from '@remix-run/react';
import { getResponsiveClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface SidebarProps {
isCollapsed: boolean;
onToggle: () => void;
isMobile: boolean;
isOpen: boolean;
onClose: () => void;
userAuthLevel: number;
}
interface NavigationItem {
name: string;
href: string;
icon: ReactNode;
authLevel?: number; // Minimum auth level required
}
const navigationItems: NavigationItem[] = [
{
name: 'لوحة التحكم',
href: '/dashboard',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5a2 2 0 012-2h4a2 2 0 012 2v6H8V5z" />
</svg>
),
},
{
name: 'العملاء',
href: '/customers',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
),
},
{
name: 'المركبات',
href: '/vehicles',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6 0a1 1 0 001 1h4a1 1 0 001-1m-6 0V9a1 1 0 00-1-1v0a1 1 0 00-1 1v8a1 1 0 001 1z" />
</svg>
),
},
{
name: 'زيارات الصيانة',
href: '/maintenance-visits',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
},
{
name: 'المصروفات',
href: '/expenses',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
),
authLevel: 2, // Admin and above
},
{
name: 'التقارير المالية',
href: '/financial-reports',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
authLevel: 2, // Admin and above
},
{
name: 'إدارة المستخدمين',
href: '/users',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
),
authLevel: 2, // Admin and above
},
{
name: 'إعدادات النظام',
href: '/settings',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
),
authLevel: 2, // Admin and above
},
];
export function Sidebar({ isCollapsed, onToggle, isMobile, isOpen, onClose, userAuthLevel }: SidebarProps) {
const location = useLocation();
// Filter navigation items based on user auth level
const filteredNavItems = navigationItems.filter(item =>
!item.authLevel || userAuthLevel <= item.authLevel
);
// Close sidebar on route change for mobile
useEffect(() => {
if (isMobile && isOpen) {
onClose();
}
}, [location.pathname, isMobile, isOpen, onClose]);
if (isMobile) {
return (
<>
{/* Mobile overlay */}
{isOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity duration-300"
onClick={onClose}
/>
)}
{/* Mobile Sidebar */}
<div className={`
fixed top-0 right-0 h-full w-64 bg-white shadow-lg z-50 transform transition-transform duration-300 ease-in-out
${isOpen ? 'translate-x-0' : 'translate-x-full'}
`} dir="rtl">
{/* Mobile Header */}
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
<div className="flex items-center">
<div className="h-8 w-8 bg-blue-600 rounded-full flex items-center justify-center">
<svg className="h-5 w-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h1 className="ml-3 text-lg font-semibold text-gray-900">نظام الصيانة</h1>
</div>
<button
onClick={onClose}
className="p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100"
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Mobile Navigation */}
<nav className="mt-5 px-2 flex-1 overflow-y-auto">
<div className="space-y-1">
{filteredNavItems.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
className={`
group flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-150 relative
${isActive ? 'bg-blue-100 text-blue-900' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}
`}
>
{isActive && <div className="absolute right-0 top-0 bottom-0 w-1 bg-blue-600 rounded-l-md"></div>}
<div className={`flex-shrink-0 ${isActive ? 'text-blue-600' : 'text-gray-400 group-hover:text-gray-500'}`}>
{item.icon}
</div>
<span className="ml-3">{item.name}</span>
</Link>
);
})}
</div>
</nav>
</div>
</>
);
}
// Desktop Sidebar
return (
<div className={`
fixed top-0 right-0 h-full bg-white shadow-lg z-40 transition-all duration-300 ease-in-out
${isCollapsed ? 'w-16' : 'w-64'}
`} dir="rtl">
{/* Desktop Header */}
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
<div className="flex items-center flex-1">
<div className="h-8 w-8 bg-blue-600 rounded-full flex items-center justify-center">
<svg className="h-5 w-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
{!isCollapsed && (
<h1 className="ml-3 text-lg font-semibold text-gray-900">نظام الصيانة</h1>
)}
</div>
<button
onClick={onToggle}
className="p-1 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<svg
className={`h-4 w-4 transform transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
</div>
{/* Desktop Navigation */}
<nav className="mt-5 px-2 flex-1 overflow-y-auto">
<div className="space-y-1">
{filteredNavItems.map((item) => {
const isActive = location.pathname === item.href;
return (
<Link
key={item.name}
to={item.href}
className={`
group flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-150 relative
${isActive ? 'bg-blue-100 text-blue-900' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}
${isCollapsed ? 'justify-center' : ''}
`}
title={isCollapsed ? item.name : undefined}
>
{isActive && <div className="absolute right-0 top-0 bottom-0 w-1 bg-blue-600 rounded-l-md"></div>}
<div className={`flex-shrink-0 ${isActive ? 'text-blue-600' : 'text-gray-400 group-hover:text-gray-500'}`}>
{item.icon}
</div>
{!isCollapsed && (
<span className="ml-3 truncate">{item.name}</span>
)}
</Link>
);
})}
</div>
</nav>
{/* Desktop Footer */}
{!isCollapsed && (
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
<div className="text-xs text-gray-500 text-center">
نظام إدارة صيانة السيارات
</div>
</div>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,92 @@
import { ReactNode, ButtonHTMLAttributes } from 'react';
import { getButtonClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className'> {
children: ReactNode;
className?: string;
config?: Partial<LayoutConfig>;
variant?: 'primary' | 'secondary' | 'danger' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
loading?: boolean;
icon?: ReactNode;
iconPosition?: 'start' | 'end';
}
export function Button({
children,
className = '',
config = {},
variant = 'primary',
size = 'md',
fullWidth = false,
loading = false,
icon,
iconPosition = 'start',
disabled,
...props
}: ButtonProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const baseClasses = getButtonClasses(variant as any, size);
const variantClasses = {
primary: 'bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500 disabled:bg-blue-300',
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-900 focus:ring-gray-500 disabled:bg-gray-100',
danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500 disabled:bg-red-300',
outline: 'border border-gray-300 bg-white hover:bg-gray-50 text-gray-700 focus:ring-blue-500 disabled:bg-gray-50',
ghost: 'bg-transparent hover:bg-gray-100 text-gray-700 focus:ring-gray-500 disabled:text-gray-400',
};
const fullWidthClass = fullWidth ? 'w-full' : '';
const disabledClass = (disabled || loading) ? 'cursor-not-allowed opacity-50' : '';
const iconSpacing = layoutConfig.direction === 'rtl' ? 'space-x-reverse' : '';
const iconOrderClass = iconPosition === 'end' ? 'flex-row-reverse' : '';
return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${fullWidthClass} ${disabledClass} ${iconSpacing} ${iconOrderClass} ${className}`}
disabled={disabled || loading}
dir={layoutConfig.direction}
{...props}
>
{loading && (
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-current"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{icon && iconPosition === 'start' && !loading && (
<span className={layoutConfig.direction === 'rtl' ? 'ml-2' : 'mr-2'}>
{icon}
</span>
)}
<span>{children}</span>
{icon && iconPosition === 'end' && !loading && (
<span className={layoutConfig.direction === 'rtl' ? 'mr-2' : 'ml-2'}>
{icon}
</span>
)}
</button>
);
}

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

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

View File

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

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

@@ -0,0 +1,233 @@
import { ReactNode, FormHTMLAttributes } from 'react';
import { Form as RemixForm } from '@remix-run/react';
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
import { Button } from './Button';
import { Text } from './Text';
interface FormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, 'className'> {
children: ReactNode;
className?: string;
config?: Partial<LayoutConfig>;
title?: string;
description?: string;
loading?: boolean;
error?: string;
success?: string;
actions?: ReactNode;
spacing?: 'sm' | 'md' | 'lg';
}
export function Form({
children,
className = '',
config = {},
title,
description,
loading = false,
error,
success,
actions,
spacing = 'md',
...props
}: FormProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const spacingClasses = {
sm: 'space-y-4',
md: 'space-y-6',
lg: 'space-y-8',
};
return (
<div className={`${className}`} dir={layoutConfig.direction}>
{/* Form Header */}
{(title || description) && (
<div className="mb-6">
{title && (
<Text as="h2" size="xl" weight="semibold" className="mb-2">
{title}
</Text>
)}
{description && (
<Text color="secondary">
{description}
</Text>
)}
</div>
)}
{/* Success Message */}
{success && (
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-center">
<svg className="h-5 w-5 text-green-400 ml-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
<Text color="success" size="sm">
{success}
</Text>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center">
<svg className="h-5 w-5 text-red-400 ml-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<Text color="error" size="sm">
{error}
</Text>
</div>
</div>
)}
{/* Form Content */}
<RemixForm className={spacingClasses[spacing]} {...props}>
{children}
{/* Form Actions */}
{actions && (
<div className="pt-4 border-t border-gray-200">
{actions}
</div>
)}
</RemixForm>
{/* Loading Overlay */}
{loading && (
<div className="absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center rounded-lg">
<div className="flex items-center space-x-2 space-x-reverse">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
<Text color="secondary">جاري المعالجة...</Text>
</div>
</div>
)}
</div>
);
}
// Form Actions Component
interface FormActionsProps {
children: ReactNode;
className?: string;
config?: Partial<LayoutConfig>;
justify?: 'start' | 'center' | 'end' | 'between';
spacing?: 'sm' | 'md' | 'lg';
}
export function FormActions({
children,
className = '',
config = {},
justify = 'end',
spacing = 'md',
}: FormActionsProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const justifyClasses = {
start: 'justify-start',
center: 'justify-center',
end: 'justify-end',
between: 'justify-between',
};
const spacingClasses = {
sm: 'space-x-2 space-x-reverse',
md: 'space-x-4 space-x-reverse',
lg: 'space-x-6 space-x-reverse',
};
return (
<div
className={`flex ${justifyClasses[justify]} ${spacingClasses[spacing]} ${className}`}
dir={layoutConfig.direction}
>
{children}
</div>
);
}
// Form Section Component
interface FormSectionProps {
children: ReactNode;
title?: string;
description?: string;
className?: string;
config?: Partial<LayoutConfig>;
}
export function FormSection({
children,
title,
description,
className = '',
config = {},
}: FormSectionProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
return (
<div className={`${className}`} dir={layoutConfig.direction}>
{(title || description) && (
<div className="mb-4">
{title && (
<Text as="h3" size="lg" weight="medium" className="mb-1">
{title}
</Text>
)}
{description && (
<Text color="secondary" size="sm">
{description}
</Text>
)}
</div>
)}
<div className="space-y-4">
{children}
</div>
</div>
);
}
// Form Grid Component
interface FormGridProps {
children: ReactNode;
columns?: 1 | 2 | 3 | 4;
className?: string;
config?: Partial<LayoutConfig>;
gap?: 'sm' | 'md' | 'lg';
}
export function FormGrid({
children,
columns = 2,
className = '',
config = {},
gap = 'md',
}: FormGridProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const columnClasses = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
};
const gapClasses = {
sm: 'gap-4',
md: 'gap-6',
lg: 'gap-8',
};
return (
<div
className={`grid ${columnClasses[columns]} ${gapClasses[gap]} ${className}`}
dir={layoutConfig.direction}
>
{children}
</div>
);
}

View File

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

View File

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

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

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

View File

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

View File

@@ -0,0 +1,79 @@
import { useState, useEffect } from 'react';
import { Input } from './Input';
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
interface SearchInputProps {
placeholder?: string;
onSearch: (query: string) => void;
debounceMs?: number;
className?: string;
config?: Partial<LayoutConfig>;
initialValue?: string;
}
export function SearchInput({
placeholder = "البحث...",
onSearch,
debounceMs = 300,
className = '',
config = {},
initialValue = '',
}: SearchInputProps) {
const layoutConfig = { ...defaultLayoutConfig, ...config };
const [query, setQuery] = useState(initialValue);
useEffect(() => {
const timer = setTimeout(() => {
onSearch(query);
}, debounceMs);
return () => clearTimeout(timer);
}, [query, onSearch, debounceMs]);
return (
<div className={`relative ${className}`} dir={layoutConfig.direction}>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<svg
className="h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<Input
type="text"
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pr-10"
/>
{query && (
<button
onClick={() => setQuery('')}
className="absolute inset-y-0 left-0 pl-3 flex items-center"
>
<svg
className="h-5 w-5 text-gray-400 hover:text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

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

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

View File

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

84
app/root.tsx Normal file
View File

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

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

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

View File

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

View File

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

View File

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

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

@@ -0,0 +1,387 @@
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData, useActionData, useNavigation, useSearchParams } from "@remix-run/react";
import { useState, useEffect } from "react";
import { requireUser } from "~/lib/auth.server";
import { useDebounce } from "~/hooks/useDebounce";
import {
getCustomers,
createCustomer,
updateCustomer,
deleteCustomer,
getCustomerById
} from "~/lib/customer-management.server";
import { validateCustomer } from "~/lib/validation";
import { DashboardLayout } from "~/components/layout/DashboardLayout";
import { CustomerList } from "~/components/customers/CustomerList";
import { CustomerForm } from "~/components/customers/CustomerForm";
import { CustomerDetailsView } from "~/components/customers/CustomerDetailsView";
import { Modal } from "~/components/ui/Modal";
import { Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input";
import { Flex } from "~/components/layout/Flex";
import type { CustomerWithVehicles } from "~/types/database";
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireUser(request);
const url = new URL(request.url);
const searchQuery = url.searchParams.get("search") || "";
const page = parseInt(url.searchParams.get("page") || "1");
const limit = parseInt(url.searchParams.get("limit") || "10");
const { customers, total, totalPages } = await getCustomers(searchQuery, page, limit);
return json({
customers,
total,
totalPages,
currentPage: page,
searchQuery,
user,
});
}
export async function action({ request }: ActionFunctionArgs) {
const user = await requireUser(request);
const formData = await request.formData();
const action = formData.get("_action") as string;
switch (action) {
case "create": {
const customerData = {
name: formData.get("name") as string,
phone: formData.get("phone") as string || undefined,
email: formData.get("email") as string || undefined,
address: formData.get("address") as string || undefined,
};
// Validate customer data
const validation = validateCustomer(customerData);
if (!validation.isValid) {
return json({
success: false,
errors: validation.errors,
action: "create"
}, { status: 400 });
}
const result = await createCustomer(customerData);
if (result.success) {
return json({
success: true,
customer: result.customer,
action: "create",
message: "تم إنشاء العميل بنجاح"
});
} else {
return json({
success: false,
error: result.error,
action: "create"
}, { status: 400 });
}
}
case "update": {
const id = parseInt(formData.get("id") as string);
const customerData = {
name: formData.get("name") as string,
phone: formData.get("phone") as string || undefined,
email: formData.get("email") as string || undefined,
address: formData.get("address") as string || undefined,
};
// Validate customer data
const validation = validateCustomer(customerData);
if (!validation.isValid) {
return json({
success: false,
errors: validation.errors,
action: "update"
}, { status: 400 });
}
const result = await updateCustomer(id, customerData);
if (result.success) {
return json({
success: true,
customer: result.customer,
action: "update",
message: "تم تحديث العميل بنجاح"
});
} else {
return json({
success: false,
error: result.error,
action: "update"
}, { status: 400 });
}
}
case "delete": {
const id = parseInt(formData.get("id") as string);
const result = await deleteCustomer(id);
if (result.success) {
return json({
success: true,
action: "delete",
message: "تم حذف العميل بنجاح"
});
} else {
return json({
success: false,
error: result.error,
action: "delete"
}, { status: 400 });
}
}
case "get": {
const id = parseInt(formData.get("id") as string);
const customer = await getCustomerById(id);
if (customer) {
return json({
success: true,
customer,
action: "get"
});
} else {
return json({
success: false,
error: "العميل غير موجود",
action: "get"
}, { status: 404 });
}
}
default:
return json({
success: false,
error: "إجراء غير صحيح",
action: "unknown"
}, { status: 400 });
}
}
export default function CustomersPage() {
const { customers, total, totalPages, currentPage, searchQuery, user } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const [searchParams, setSearchParams] = useSearchParams();
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showViewModal, setShowViewModal] = useState(false);
const [selectedCustomer, setSelectedCustomer] = useState<CustomerWithVehicles | null>(null);
const [searchValue, setSearchValue] = useState(searchQuery);
const isLoading = navigation.state !== "idle";
// Debounce search value to avoid too many requests
const debouncedSearchValue = useDebounce(searchValue, 300);
// Handle search automatically when debounced value changes
useEffect(() => {
if (debouncedSearchValue !== searchQuery) {
const newSearchParams = new URLSearchParams(searchParams);
if (debouncedSearchValue) {
newSearchParams.set("search", debouncedSearchValue);
} else {
newSearchParams.delete("search");
}
newSearchParams.set("page", "1"); // Reset to first page
setSearchParams(newSearchParams);
}
}, [debouncedSearchValue, searchQuery, searchParams, setSearchParams]);
// Clear search function
const clearSearch = () => {
setSearchValue("");
};
// Handle pagination
const handlePageChange = (page: number) => {
const newSearchParams = new URLSearchParams(searchParams);
newSearchParams.set("page", page.toString());
setSearchParams(newSearchParams);
};
// Handle create customer
const handleCreateCustomer = () => {
setSelectedCustomer(null);
setShowCreateModal(true);
};
// Handle view customer
const handleViewCustomer = (customer: CustomerWithVehicles) => {
setSelectedCustomer(customer);
setShowViewModal(true);
};
// Handle edit customer
const handleEditCustomer = (customer: CustomerWithVehicles) => {
setSelectedCustomer(customer);
setShowEditModal(true);
};
// Close modals on successful action
useEffect(() => {
if (actionData?.success && actionData.action === "create") {
setShowCreateModal(false);
}
if (actionData?.success && actionData.action === "update") {
setShowEditModal(false);
}
}, [actionData]);
return (
<DashboardLayout user={user}>
<div className="space-y-6">
{/* Header */}
<Flex justify="between" align="center" className="flex-wrap gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">إدارة العملاء</h1>
<p className="text-gray-600 mt-1">
إجمالي العملاء: {total}
</p>
</div>
<Button
onClick={handleCreateCustomer}
disabled={isLoading}
className="bg-blue-600 hover:bg-blue-700"
>
إضافة عميل جديد
</Button>
</Flex>
{/* Search */}
<div className="bg-white p-4 rounded-lg shadow-sm border">
<Flex gap="md" align="center" className="flex-wrap">
<div className="flex-1 min-w-64">
<Input
type="text"
placeholder="البحث في العملاء... (البحث تلقائي)"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
startIcon={
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
}
endIcon={
searchValue && (
<div className="pointer-events-auto">
<button
onClick={clearSearch}
className="text-gray-400 hover:text-gray-600"
type="button"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)
}
/>
</div>
{(searchQuery || debouncedSearchValue !== searchQuery) && (
<div className="flex items-center text-sm text-gray-500">
{debouncedSearchValue !== searchQuery && (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
جاري البحث...
</span>
)}
</div>
)}
</Flex>
</div>
{/* Action Messages */}
{actionData?.success && actionData.message && (
<div className="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg">
{actionData.message}
</div>
)}
{actionData?.error && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg">
{actionData.error}
</div>
)}
{/* Customer List */}
<CustomerList
customers={customers}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
onViewCustomer={handleViewCustomer}
onEditCustomer={handleEditCustomer}
isLoading={isLoading}
actionData={actionData}
/>
{/* Create Customer Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="إضافة عميل جديد"
>
<CustomerForm
onCancel={() => setShowCreateModal(false)}
errors={actionData?.action === "create" ? actionData.errors : undefined}
isLoading={isLoading}
/>
</Modal>
{/* View Customer Modal */}
<Modal
isOpen={showViewModal}
onClose={() => setShowViewModal(false)}
title={selectedCustomer ? `تفاصيل العميل - ${selectedCustomer.name}` : "تفاصيل العميل"}
size="xl"
>
{selectedCustomer && (
<CustomerDetailsView
customer={selectedCustomer}
onEdit={() => {
setShowViewModal(false);
handleEditCustomer(selectedCustomer);
}}
onClose={() => setShowViewModal(false)}
/>
)}
</Modal>
{/* Edit Customer Modal */}
<Modal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
title="تعديل العميل"
>
{selectedCustomer && (
<CustomerForm
customer={selectedCustomer}
onCancel={() => setShowEditModal(false)}
errors={actionData?.action === "update" ? actionData.errors : undefined}
isLoading={isLoading}
/>
)}
</Modal>
</div>
</DashboardLayout>
);
}

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

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

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

@@ -0,0 +1,498 @@
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { useLoaderData, useActionData, useNavigation, useSearchParams } from "@remix-run/react";
import { useState, useEffect } from "react";
import { requireAuth } from "~/lib/auth-middleware.server";
import { useDebounce } from "~/hooks/useDebounce";
import {
getExpenses,
createExpense,
updateExpense,
deleteExpense,
getExpenseById,
getExpenseCategories
} from "~/lib/expense-management.server";
import { validateExpense } from "~/lib/validation";
import { DashboardLayout } from "~/components/layout/DashboardLayout";
import { ExpenseForm } from "~/components/expenses/ExpenseForm";
import { Button } from "~/components/ui/Button";
import { Input } from "~/components/ui/Input";
import { Modal } from "~/components/ui/Modal";
import { DataTable } from "~/components/ui/DataTable";
import { Flex } from "~/components/layout/Flex";
import { useSettings } from "~/contexts/SettingsContext";
import { EXPENSE_CATEGORIES, PAGINATION } from "~/lib/constants";
import type { Expense } from "@prisma/client";
export async function loader({ request }: LoaderFunctionArgs) {
const user = await requireAuth(request, 2); // Admin level required
const url = new URL(request.url);
const searchQuery = url.searchParams.get("search") || "";
const page = parseInt(url.searchParams.get("page") || "1");
const category = url.searchParams.get("category") || "";
const dateFrom = url.searchParams.get("dateFrom")
? new Date(url.searchParams.get("dateFrom")!)
: undefined;
const dateTo = url.searchParams.get("dateTo")
? new Date(url.searchParams.get("dateTo")!)
: undefined;
const { expenses, total, totalPages } = await getExpenses(
searchQuery,
page,
PAGINATION.DEFAULT_PAGE_SIZE,
category,
dateFrom,
dateTo
);
const categories = await getExpenseCategories();
return json({
user,
expenses,
total,
totalPages,
currentPage: page,
searchQuery,
category,
dateFrom: dateFrom?.toISOString().split('T')[0] || "",
dateTo: dateTo?.toISOString().split('T')[0] || "",
categories,
});
}
export async function action({ request }: ActionFunctionArgs) {
await requireAuth(request, 2); // Admin level required
const formData = await request.formData();
const action = formData.get("_action") as string;
switch (action) {
case "create": {
const expenseData = {
description: formData.get("description") as string,
category: formData.get("category") as string,
amount: parseFloat(formData.get("amount") as string),
expenseDate: formData.get("expenseDate") as string,
};
const validation = validateExpense({
description: expenseData.description,
category: expenseData.category,
amount: expenseData.amount,
});
if (!validation.isValid) {
return json({
success: false,
errors: validation.errors,
action: "create"
}, { status: 400 });
}
try {
const expense = await createExpense({
description: expenseData.description,
category: expenseData.category,
amount: expenseData.amount,
expenseDate: expenseData.expenseDate ? new Date(expenseData.expenseDate) : undefined,
});
return json({
success: true,
expense,
action: "create",
message: "تم إنشاء المصروف بنجاح"
});
} catch (error) {
return json({
success: false,
error: "حدث خطأ أثناء إضافة المصروف",
action: "create"
}, { status: 500 });
}
}
case "update": {
const id = parseInt(formData.get("id") as string);
const expenseData = {
description: formData.get("description") as string,
category: formData.get("category") as string,
amount: parseFloat(formData.get("amount") as string),
expenseDate: formData.get("expenseDate") as string,
};
const validation = validateExpense({
description: expenseData.description,
category: expenseData.category,
amount: expenseData.amount,
});
if (!validation.isValid) {
return json({
success: false,
errors: validation.errors,
action: "update"
}, { status: 400 });
}
try {
const expense = await updateExpense(id, {
description: expenseData.description,
category: expenseData.category,
amount: expenseData.amount,
expenseDate: expenseData.expenseDate ? new Date(expenseData.expenseDate) : undefined,
});
return json({
success: true,
expense,
action: "update",
message: "تم تحديث المصروف بنجاح"
});
} catch (error) {
return json({
success: false,
error: "حدث خطأ أثناء تحديث المصروف",
action: "update"
}, { status: 500 });
}
}
case "delete": {
const id = parseInt(formData.get("id") as string);
try {
await deleteExpense(id);
return json({
success: true,
action: "delete",
message: "تم حذف المصروف بنجاح"
});
} catch (error) {
return json({
success: false,
error: "حدث خطأ أثناء حذف المصروف",
action: "delete"
}, { status: 500 });
}
}
case "get": {
const id = parseInt(formData.get("id") as string);
const expense = await getExpenseById(id);
if (expense) {
return json({
success: true,
expense,
action: "get"
});
} else {
return json({
success: false,
error: "المصروف غير موجود",
action: "get"
}, { status: 404 });
}
}
default:
return json({
success: false,
error: "إجراء غير صحيح",
action: "unknown"
}, { status: 400 });
}
}
export default function ExpensesPage() {
const { formatCurrency, formatDate } = useSettings();
const {
user,
expenses,
total,
totalPages,
currentPage,
searchQuery,
category,
dateFrom,
dateTo,
categories
} = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const [searchParams, setSearchParams] = useSearchParams();
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedExpense, setSelectedExpense] = useState<Expense | null>(null);
const [searchValue, setSearchValue] = useState(searchQuery);
const isLoading = navigation.state !== "idle";
// Debounce search value to avoid too many requests
const debouncedSearchValue = useDebounce(searchValue, 300);
// Handle search automatically when debounced value changes
useEffect(() => {
if (debouncedSearchValue !== searchQuery) {
const newSearchParams = new URLSearchParams(searchParams);
if (debouncedSearchValue) {
newSearchParams.set("search", debouncedSearchValue);
} else {
newSearchParams.delete("search");
}
newSearchParams.set("page", "1"); // Reset to first page
setSearchParams(newSearchParams);
}
}, [debouncedSearchValue, searchQuery, searchParams, setSearchParams]);
// Clear search function
const clearSearch = () => {
setSearchValue("");
};
const handleFilter = (filterType: string, value: string) => {
const newParams = new URLSearchParams(searchParams);
if (value) {
newParams.set(filterType, value);
} else {
newParams.delete(filterType);
}
newParams.set("page", "1");
setSearchParams(newParams);
};
// Handle pagination
const handlePageChange = (page: number) => {
const newParams = new URLSearchParams(searchParams);
newParams.set("page", page.toString());
setSearchParams(newParams);
};
// Handle create expense
const handleCreateExpense = () => {
setSelectedExpense(null);
setShowCreateModal(true);
};
// Handle edit expense
const handleEditExpense = (expense: Expense) => {
setSelectedExpense(expense);
setShowEditModal(true);
};
// Close modals on successful action
useEffect(() => {
if (actionData?.success && actionData.action === "create") {
setShowCreateModal(false);
}
if (actionData?.success && actionData.action === "update") {
setShowEditModal(false);
}
}, [actionData]);
const columns = [
{
key: "description",
header: "الوصف",
render: (expense: Expense) => expense.description,
},
{
key: "category",
header: "الفئة",
render: (expense: Expense) => {
const categoryLabel = EXPENSE_CATEGORIES.find(c => c.value === expense.category)?.label;
return categoryLabel || expense.category;
},
},
{
key: "amount",
header: "المبلغ",
render: (expense: Expense) => formatCurrency(expense.amount),
},
{
key: "expenseDate",
header: "تاريخ المصروف",
render: (expense: Expense) => formatDate(expense.expenseDate),
},
{
key: "createdDate",
header: "تاريخ الإضافة",
render: (expense: Expense) => formatDate(expense.createdDate),
},
{
key: "actions",
header: "الإجراءات",
render: (expense: Expense) => (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEditExpense(expense)}
disabled={isLoading}
>
تعديل
</Button>
</div>
),
},
];
return (
<DashboardLayout user={user}>
<div className="space-y-6">
{/* Header */}
<Flex justify="between" align="center" className="flex-wrap gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">إدارة المصروفات</h1>
<p className="text-gray-600 mt-1">
إجمالي المصروفات: {total}
</p>
</div>
<Button
onClick={handleCreateExpense}
disabled={isLoading}
className="bg-blue-600 hover:bg-blue-700"
>
إضافة مصروف جديد
</Button>
</Flex>
{/* Search */}
<div className="bg-white p-4 rounded-lg shadow-sm border">
<Flex gap="md" align="center" className="flex-wrap">
<div className="flex-1 min-w-64">
<Input
type="text"
placeholder="البحث في المصروفات... (البحث تلقائي)"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
startIcon={
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
}
endIcon={
searchValue && (
<div className="pointer-events-auto">
<button
onClick={clearSearch}
className="text-gray-400 hover:text-gray-600"
type="button"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)
}
/>
</div>
{(searchQuery || debouncedSearchValue !== searchQuery) && (
<div className="flex items-center text-sm text-gray-500">
{debouncedSearchValue !== searchQuery && (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
جاري البحث...
</span>
)}
</div>
)}
</Flex>
</div>
{/* Filters */}
<div className="bg-white p-4 rounded-lg shadow-sm border">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<select
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
value={category}
onChange={(e) => handleFilter("category", e.target.value)}
>
<option value="">جميع الفئات</option>
{EXPENSE_CATEGORIES.map((cat) => (
<option key={cat.value} value={cat.value}>
{cat.label}
</option>
))}
</select>
<Input
type="date"
placeholder="من تاريخ"
value={dateFrom}
onChange={(e) => handleFilter("dateFrom", e.target.value)}
/>
<Input
type="date"
placeholder="إلى تاريخ"
value={dateTo}
onChange={(e) => handleFilter("dateTo", e.target.value)}
/>
</div>
</div>
{/* Action Messages */}
{actionData?.success && actionData.message && (
<div className="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded-lg">
{actionData.message}
</div>
)}
{actionData?.error && (
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg">
{actionData.error}
</div>
)}
{/* Expenses Table */}
<DataTable
data={expenses}
columns={columns}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
{/* Create Expense Modal */}
<Modal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="إضافة مصروف جديد"
>
<ExpenseForm
onCancel={() => setShowCreateModal(false)}
errors={actionData?.action === "create" ? actionData.errors : undefined}
isLoading={isLoading}
/>
</Modal>
{/* Edit Expense Modal */}
<Modal
isOpen={showEditModal}
onClose={() => setShowEditModal(false)}
title="تعديل المصروف"
>
{selectedExpense && (
<ExpenseForm
expense={selectedExpense}
onCancel={() => setShowEditModal(false)}
errors={actionData?.action === "update" ? actionData.errors : undefined}
isLoading={isLoading}
/>
)}
</Modal>
</div>
</DashboardLayout>
);
}

View File

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

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

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

Some files were not shown because too many files have changed in this diff Show More