uup
This commit is contained in:
84
.eslintrc.cjs
Normal file
84
.eslintrc.cjs
Normal 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
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
|
||||
/.cache
|
||||
/build
|
||||
.env
|
||||
|
||||
/generated/prisma
|
||||
399
.kiro/specs/car-maintenance-system/design.md
Normal file
399
.kiro/specs/car-maintenance-system/design.md
Normal 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
|
||||
165
.kiro/specs/car-maintenance-system/requirements.md
Normal file
165
.kiro/specs/car-maintenance-system/requirements.md
Normal 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
|
||||
201
.kiro/specs/car-maintenance-system/tasks.md
Normal file
201
.kiro/specs/car-maintenance-system/tasks.md
Normal 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
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"kiroAgent.configureMCP": "Disabled"
|
||||
}
|
||||
117
MAINTENANCE_TYPE_MIGRATION_SUMMARY.md
Normal file
117
MAINTENANCE_TYPE_MIGRATION_SUMMARY.md
Normal 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
250
ROUTE_PROTECTION.md
Normal 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.
|
||||
233
SETTINGS_IMPLEMENTATION_SUMMARY.md
Normal file
233
SETTINGS_IMPLEMENTATION_SUMMARY.md
Normal 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
298
SETTINGS_SYSTEM_README.md
Normal 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
144
SETTINGS_TESTING_GUIDE.md
Normal 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
363
app/components/README.md
Normal file
@@ -0,0 +1,363 @@
|
||||
# Reusable Form Components and Validation
|
||||
|
||||
This document describes the enhanced reusable form components and validation utilities implemented for the car maintenance management system.
|
||||
|
||||
## Overview
|
||||
|
||||
The system now includes a comprehensive set of reusable form components with RTL support, client-side and server-side validation, and enhanced data table functionality with Arabic text support.
|
||||
|
||||
## Components
|
||||
|
||||
### Form Input Components
|
||||
|
||||
#### Input Component (`app/components/ui/Input.tsx`)
|
||||
Enhanced input component with RTL support, validation, and icons.
|
||||
|
||||
```tsx
|
||||
import { Input } from '~/components/ui/Input';
|
||||
|
||||
<Input
|
||||
label="اسم العميل"
|
||||
placeholder="أدخل اسم العميل"
|
||||
error={errors.name}
|
||||
helperText="الاسم مطلوب"
|
||||
startIcon={<UserIcon />}
|
||||
required
|
||||
/>
|
||||
```
|
||||
|
||||
**Props:**
|
||||
- `label`: Field label
|
||||
- `error`: Error message to display
|
||||
- `helperText`: Helper text below input
|
||||
- `startIcon`/`endIcon`: Icons for input decoration
|
||||
- `fullWidth`: Whether input takes full width (default: true)
|
||||
- All standard HTML input props
|
||||
|
||||
#### Select Component (`app/components/ui/Select.tsx`)
|
||||
Dropdown select component with RTL support and validation.
|
||||
|
||||
```tsx
|
||||
import { Select } from '~/components/ui/Select';
|
||||
|
||||
<Select
|
||||
label="نوع الوقود"
|
||||
placeholder="اختر نوع الوقود"
|
||||
options={[
|
||||
{ value: 'gasoline', label: 'بنزين' },
|
||||
{ value: 'diesel', label: 'ديزل' },
|
||||
]}
|
||||
error={errors.fuel}
|
||||
/>
|
||||
```
|
||||
|
||||
**Props:**
|
||||
- `options`: Array of `{ value, label, disabled? }` objects
|
||||
- `placeholder`: Placeholder text
|
||||
- Other props same as Input component
|
||||
|
||||
#### Textarea Component (`app/components/ui/Textarea.tsx`)
|
||||
Multi-line text input with RTL support.
|
||||
|
||||
```tsx
|
||||
import { Textarea } from '~/components/ui/Textarea';
|
||||
|
||||
<Textarea
|
||||
label="العنوان"
|
||||
placeholder="أدخل العنوان"
|
||||
rows={3}
|
||||
resize="vertical"
|
||||
error={errors.address}
|
||||
/>
|
||||
```
|
||||
|
||||
**Props:**
|
||||
- `resize`: Resize behavior ('none', 'vertical', 'horizontal', 'both')
|
||||
- `rows`: Number of visible rows
|
||||
- Other props same as Input component
|
||||
|
||||
#### FormField Component (`app/components/ui/FormField.tsx`)
|
||||
Wrapper component for consistent field styling and validation display.
|
||||
|
||||
```tsx
|
||||
import { FormField } from '~/components/ui/FormField';
|
||||
|
||||
<FormField
|
||||
label="اسم العميل"
|
||||
required
|
||||
error={errors.name}
|
||||
helperText="أدخل الاسم الكامل"
|
||||
>
|
||||
<input type="text" name="name" />
|
||||
</FormField>
|
||||
```
|
||||
|
||||
### Form Layout Components
|
||||
|
||||
#### Form Component (`app/components/ui/Form.tsx`)
|
||||
Main form wrapper with title, description, and error handling.
|
||||
|
||||
```tsx
|
||||
import { Form, FormActions, FormSection, FormGrid } from '~/components/ui/Form';
|
||||
|
||||
<Form
|
||||
title="إضافة عميل جديد"
|
||||
description="أدخل بيانات العميل"
|
||||
loading={isLoading}
|
||||
error={generalError}
|
||||
success={successMessage}
|
||||
>
|
||||
<FormSection title="المعلومات الأساسية">
|
||||
<FormGrid columns={2}>
|
||||
{/* Form fields */}
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormActions>
|
||||
<Button variant="outline">إلغاء</Button>
|
||||
<Button type="submit">حفظ</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
```
|
||||
|
||||
**Components:**
|
||||
- `Form`: Main form wrapper
|
||||
- `FormActions`: Action buttons container
|
||||
- `FormSection`: Grouped form fields with title
|
||||
- `FormGrid`: Responsive grid layout for fields
|
||||
|
||||
### Enhanced Data Table
|
||||
|
||||
#### DataTable Component (`app/components/ui/DataTable.tsx`)
|
||||
Advanced data table with search, filtering, sorting, and pagination.
|
||||
|
||||
```tsx
|
||||
import { DataTable } from '~/components/ui/DataTable';
|
||||
|
||||
<DataTable
|
||||
data={customers}
|
||||
columns={[
|
||||
{
|
||||
key: 'name',
|
||||
header: 'الاسم',
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: (customer) => <strong>{customer.name}</strong>
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
header: 'الهاتف',
|
||||
filterable: true,
|
||||
filterType: 'text'
|
||||
}
|
||||
]}
|
||||
searchable
|
||||
searchPlaceholder="البحث في العملاء..."
|
||||
filterable
|
||||
pagination={{
|
||||
enabled: true,
|
||||
pageSize: 10,
|
||||
currentPage: 1,
|
||||
onPageChange: handlePageChange
|
||||
}}
|
||||
actions={{
|
||||
label: 'الإجراءات',
|
||||
render: (item) => (
|
||||
<Button onClick={() => edit(item)}>تعديل</Button>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Search across multiple fields
|
||||
- Column-based filtering
|
||||
- Sorting with Arabic text support
|
||||
- Pagination
|
||||
- Custom action buttons
|
||||
- RTL layout support
|
||||
- Loading and empty states
|
||||
|
||||
## Validation
|
||||
|
||||
### Server-Side Validation (`app/lib/form-validation.ts`)
|
||||
|
||||
Zod-based validation schemas with Arabic error messages.
|
||||
|
||||
```tsx
|
||||
import { validateCustomerData } from '~/lib/form-validation';
|
||||
|
||||
const result = validateCustomerData(formData);
|
||||
if (!result.success) {
|
||||
return json({ errors: result.errors });
|
||||
}
|
||||
```
|
||||
|
||||
**Available Validators:**
|
||||
- `validateUserData(data)`
|
||||
- `validateCustomerData(data)`
|
||||
- `validateVehicleData(data)`
|
||||
- `validateMaintenanceVisitData(data)`
|
||||
- `validateExpenseData(data)`
|
||||
|
||||
### Client-Side Validation Hook (`app/hooks/useFormValidation.ts`)
|
||||
|
||||
React hook for real-time form validation.
|
||||
|
||||
```tsx
|
||||
import { useFormValidation } from '~/hooks/useFormValidation';
|
||||
import { customerSchema } from '~/lib/form-validation';
|
||||
|
||||
const {
|
||||
values,
|
||||
errors,
|
||||
isValid,
|
||||
setValue,
|
||||
getFieldProps,
|
||||
validate
|
||||
} = useFormValidation({
|
||||
schema: customerSchema,
|
||||
initialValues: { name: '', email: '' },
|
||||
validateOnChange: true,
|
||||
validateOnBlur: true
|
||||
});
|
||||
|
||||
// Use with form fields
|
||||
<Input {...getFieldProps('name')} />
|
||||
```
|
||||
|
||||
### Validation Utilities (`app/lib/validation-utils.ts`)
|
||||
|
||||
Utility functions for field-level validation.
|
||||
|
||||
```tsx
|
||||
import { validateField, validateEmail, PATTERNS } from '~/lib/validation-utils';
|
||||
|
||||
// Single field validation
|
||||
const result = validateField(value, {
|
||||
required: true,
|
||||
minLength: 3,
|
||||
email: true
|
||||
});
|
||||
|
||||
// Specific validators
|
||||
const emailResult = validateEmail('test@example.com');
|
||||
const phoneResult = validatePhone('+966501234567');
|
||||
|
||||
// Pattern matching
|
||||
const isValidEmail = PATTERNS.email.test(email);
|
||||
```
|
||||
|
||||
## Table Utilities (`app/lib/table-utils.ts`)
|
||||
|
||||
Utilities for data processing with Arabic text support.
|
||||
|
||||
```tsx
|
||||
import {
|
||||
searchData,
|
||||
filterData,
|
||||
sortData,
|
||||
processTableData
|
||||
} from '~/lib/table-utils';
|
||||
|
||||
// Process table data with search, filter, sort, and pagination
|
||||
const result = processTableData(
|
||||
data,
|
||||
{
|
||||
search: 'محمد',
|
||||
filters: { status: 'active' },
|
||||
sort: { key: 'name', direction: 'asc' },
|
||||
pagination: { page: 1, pageSize: 10 }
|
||||
},
|
||||
['name', 'email'] // searchable fields
|
||||
);
|
||||
```
|
||||
|
||||
## Example Forms
|
||||
|
||||
### Enhanced Customer Form (`app/components/forms/EnhancedCustomerForm.tsx`)
|
||||
|
||||
Complete example showing all components working together:
|
||||
|
||||
```tsx
|
||||
import { EnhancedCustomerForm } from '~/components/forms/EnhancedCustomerForm';
|
||||
|
||||
<EnhancedCustomerForm
|
||||
customer={customer}
|
||||
onCancel={() => setShowForm(false)}
|
||||
errors={actionData?.errors}
|
||||
isLoading={navigation.state === 'submitting'}
|
||||
onSubmit={(data) => submit(data, { method: 'post' })}
|
||||
/>
|
||||
```
|
||||
|
||||
### Enhanced Vehicle Form (`app/components/forms/EnhancedVehicleForm.tsx`)
|
||||
|
||||
Complex form with multiple sections and validation:
|
||||
|
||||
```tsx
|
||||
import { EnhancedVehicleForm } from '~/components/forms/EnhancedVehicleForm';
|
||||
|
||||
<EnhancedVehicleForm
|
||||
vehicle={vehicle}
|
||||
customers={customers}
|
||||
onCancel={() => setShowForm(false)}
|
||||
errors={actionData?.errors}
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### RTL Support
|
||||
- All components support right-to-left layout
|
||||
- Arabic text rendering and alignment
|
||||
- Proper icon and element positioning
|
||||
|
||||
### Validation
|
||||
- Client-side real-time validation
|
||||
- Server-side validation with Zod schemas
|
||||
- Arabic error messages
|
||||
- Field-level and form-level validation
|
||||
|
||||
### Accessibility
|
||||
- Proper ARIA labels and descriptions
|
||||
- Keyboard navigation support
|
||||
- Screen reader compatibility
|
||||
- Focus management
|
||||
|
||||
### Performance
|
||||
- Memoized components to prevent unnecessary re-renders
|
||||
- Debounced search functionality
|
||||
- Efficient data processing utilities
|
||||
- Lazy loading for large datasets
|
||||
|
||||
## Usage Guidelines
|
||||
|
||||
1. **Always use FormField wrapper** for consistent styling and error display
|
||||
2. **Implement both client and server validation** for security and UX
|
||||
3. **Use the validation hook** for real-time feedback
|
||||
4. **Leverage table utilities** for consistent data processing
|
||||
5. **Follow RTL design patterns** for Arabic text and layout
|
||||
6. **Test with Arabic content** to ensure proper rendering
|
||||
|
||||
## Migration from Old Components
|
||||
|
||||
To migrate existing forms to use the new components:
|
||||
|
||||
1. Replace basic inputs with the new Input/Select/Textarea components
|
||||
2. Wrap fields with FormField for consistent styling
|
||||
3. Add validation using the useFormValidation hook
|
||||
4. Update data tables to use the enhanced DataTable component
|
||||
5. Use Form layout components for better structure
|
||||
|
||||
## Testing
|
||||
|
||||
All components include comprehensive tests:
|
||||
- `app/lib/__tests__/form-validation.test.ts`
|
||||
- `app/lib/__tests__/validation-utils.test.ts`
|
||||
|
||||
Run tests with:
|
||||
```bash
|
||||
npm run test -- --run app/lib/__tests__/form-validation.test.ts
|
||||
```
|
||||
313
app/components/customers/CustomerDetailsView.tsx
Normal file
313
app/components/customers/CustomerDetailsView.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Flex } from "~/components/layout/Flex";
|
||||
import { useSettings } from "~/contexts/SettingsContext";
|
||||
import { PAYMENT_STATUS_NAMES } from "~/lib/constants";
|
||||
import type { CustomerWithVehicles } from "~/types/database";
|
||||
|
||||
interface CustomerDetailsViewProps {
|
||||
customer: CustomerWithVehicles;
|
||||
onEdit: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CustomerDetailsView({
|
||||
customer,
|
||||
onEdit,
|
||||
onClose,
|
||||
}: CustomerDetailsViewProps) {
|
||||
const { formatDate, formatCurrency } = useSettings();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Enhanced Basic Information Section */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 p-6 rounded-xl border border-blue-100">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-bold text-gray-900 flex items-center">
|
||||
<span className="text-blue-600 ml-2">👤</span>
|
||||
المعلومات الأساسية
|
||||
</h3>
|
||||
<span className="text-sm text-gray-500 bg-white px-3 py-1 rounded-full">
|
||||
العميل #{customer.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">اسم العميل</label>
|
||||
<p className="text-lg font-semibold text-gray-900">{customer.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">رقم الهاتف</label>
|
||||
<p className="text-gray-900" dir="ltr">
|
||||
{customer.phone ? (
|
||||
<a
|
||||
href={`tel:${customer.phone}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
📞 {customer.phone}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-400">غير محدد</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">البريد الإلكتروني</label>
|
||||
<p className="text-gray-900" dir="ltr">
|
||||
{customer.email ? (
|
||||
<a
|
||||
href={`mailto:${customer.email}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
✉️ {customer.email}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-gray-400">غير محدد</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">تاريخ الإنشاء</label>
|
||||
<p className="text-gray-900">
|
||||
{formatDate(customer.createdDate)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">آخر تحديث</label>
|
||||
<p className="text-gray-900">
|
||||
{formatDate(customer.updateDate)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{customer.address && (
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm md:col-span-2 lg:col-span-1">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">العنوان</label>
|
||||
<p className="text-gray-900">{customer.address}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Vehicles Section */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<Flex justify="between" align="center" className="flex-wrap gap-4">
|
||||
<div className="flex items-center">
|
||||
<span className="text-gray-600 text-xl ml-2">🚗</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
مركبات العميل ({customer.vehicles.length})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{customer.vehicles.length > 0 && (
|
||||
<Link
|
||||
to={`/vehicles?customerId=${customer.id}`}
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<span className="ml-2">🔍</span>
|
||||
عرض جميع المركبات
|
||||
</Link>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{customer.vehicles.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 text-4xl mb-4">🚗</div>
|
||||
<h4 className="text-lg font-medium text-gray-900 mb-2">لا توجد مركبات مسجلة</h4>
|
||||
<p className="text-gray-500 mb-4">لم يتم تسجيل أي مركبات لهذا العميل بعد</p>
|
||||
<Link
|
||||
to="/vehicles"
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
إضافة مركبة جديدة
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{customer.vehicles.map((vehicle) => (
|
||||
<Link
|
||||
key={vehicle.id}
|
||||
to={`/vehicles?plateNumber=${encodeURIComponent(vehicle.plateNumber)}`}
|
||||
target="_blank"
|
||||
className="block bg-gray-50 rounded-lg p-4 border border-gray-200 hover:border-blue-300 hover:bg-blue-50 transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900 text-lg">
|
||||
{vehicle.plateNumber}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500">#{vehicle.id}</p>
|
||||
</div>
|
||||
<span className="text-xs text-blue-600 bg-blue-100 px-2 py-1 rounded-full">
|
||||
انقر للعرض
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">الصانع:</span>
|
||||
<span className="text-sm font-medium text-gray-900">{vehicle.manufacturer}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">الموديل:</span>
|
||||
<span className="text-sm font-medium text-gray-900">{vehicle.model}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">سنة الصنع:</span>
|
||||
<span className="text-sm font-medium text-gray-900">{vehicle.year}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-600">آخر زيارة:</span>
|
||||
<span className="text-sm text-gray-900">
|
||||
{vehicle.lastVisitDate
|
||||
? formatDate(vehicle.lastVisitDate)
|
||||
: <span className="text-gray-400">لا توجد زيارات</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Latest Maintenance Visits Section */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<Flex justify="between" align="center" className="flex-wrap gap-4">
|
||||
<div className="flex items-center">
|
||||
<span className="text-gray-600 text-xl ml-2">🔧</span>
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
آخر زيارات الصيانة ({customer.maintenanceVisits.length > 3 ? '3 من ' + customer.maintenanceVisits.length : customer.maintenanceVisits.length})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{customer.maintenanceVisits.length > 0 && (
|
||||
<Link
|
||||
to={`/maintenance-visits?customerId=${customer.id}`}
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-green-600 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
|
||||
>
|
||||
<span className="ml-2">📋</span>
|
||||
عرض جميع الزيارات
|
||||
</Link>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{customer.maintenanceVisits.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 text-4xl mb-4">🔧</div>
|
||||
<h4 className="text-lg font-medium text-gray-900 mb-2">لا توجد زيارات صيانة</h4>
|
||||
<p className="text-gray-500 mb-4">لم يتم تسجيل أي زيارات صيانة لهذا العميل بعد</p>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
ابدأ بتسجيل أول زيارة صيانة لتتبع تاريخ الخدمات المقدمة
|
||||
</p>
|
||||
<Link
|
||||
to="/maintenance-visits"
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
تسجيل زيارة صيانة جديدة
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{customer.maintenanceVisits.slice(0, 3).map((visit) => (
|
||||
<div
|
||||
key={visit.id}
|
||||
className="bg-gray-50 rounded-lg p-4 border border-gray-200 hover:border-green-300 hover:bg-green-50 transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900 text-lg">
|
||||
{(() => {
|
||||
try {
|
||||
const jobs = JSON.parse(visit.maintenanceJobs);
|
||||
return jobs.length > 1
|
||||
? `${jobs.length} أعمال صيانة`
|
||||
: jobs[0]?.job || 'نوع صيانة غير محدد';
|
||||
} catch {
|
||||
return 'نوع صيانة غير محدد';
|
||||
}
|
||||
})()}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500">زيارة #{visit.id}</p>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-lg font-bold text-green-600">
|
||||
{formatCurrency(visit.cost)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">تاريخ الزيارة:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{formatDate(visit.visitDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">المركبة:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{visit.vehicle?.plateNumber || ''}
|
||||
</span>
|
||||
</div>
|
||||
{visit.description && (
|
||||
<div className="md:col-span-2">
|
||||
<span className="text-gray-600">الوصف:</span>
|
||||
<p className="text-gray-900 mt-1">{visit.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{customer.maintenanceVisits.length > 3 && (
|
||||
<div className="text-center py-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
عرض 3 من أصل {customer.maintenanceVisits.length} زيارة صيانة
|
||||
</p>
|
||||
<Link
|
||||
to={`/maintenance-visits?customerId=${customer.id}`}
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-green-600 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
|
||||
>
|
||||
<span className="ml-2">📋</span>
|
||||
عرض جميع الزيارات ({customer.maintenanceVisits.length})
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-200">
|
||||
<Button
|
||||
onClick={onEdit}
|
||||
className="bg-blue-600 hover:bg-blue-700 flex-1 sm:flex-none"
|
||||
>
|
||||
<span className="ml-2">✏️</span>
|
||||
تعديل العميل
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
إغلاق
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
app/components/customers/CustomerForm.tsx
Normal file
183
app/components/customers/CustomerForm.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Input } from "~/components/ui/Input";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Flex } from "~/components/layout/Flex";
|
||||
import type { Customer } from "~/types/database";
|
||||
|
||||
interface CustomerFormProps {
|
||||
customer?: Customer;
|
||||
onCancel: () => void;
|
||||
errors?: Record<string, string>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function CustomerForm({
|
||||
customer,
|
||||
onCancel,
|
||||
errors = {},
|
||||
isLoading,
|
||||
}: CustomerFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: customer?.name || "",
|
||||
phone: customer?.phone || "",
|
||||
email: customer?.email || "",
|
||||
address: customer?.address || "",
|
||||
});
|
||||
|
||||
// Reset form data when customer changes
|
||||
useEffect(() => {
|
||||
if (customer) {
|
||||
setFormData({
|
||||
name: customer.name || "",
|
||||
phone: customer.phone || "",
|
||||
email: customer.email || "",
|
||||
address: customer.address || "",
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
address: "",
|
||||
});
|
||||
}
|
||||
}, [customer]);
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const isEditing = !!customer;
|
||||
|
||||
return (
|
||||
<Form method="post" className="space-y-6">
|
||||
<input
|
||||
type="hidden"
|
||||
name="_action"
|
||||
value={isEditing ? "update" : "create"}
|
||||
/>
|
||||
{isEditing && (
|
||||
<input type="hidden" name="id" value={customer.id} />
|
||||
)}
|
||||
|
||||
{/* Customer Name */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
اسم العميل *
|
||||
</label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange("name", e.target.value)}
|
||||
placeholder="أدخل اسم العميل"
|
||||
error={errors.name}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone Number */}
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
رقم الهاتف
|
||||
</label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleInputChange("phone", e.target.value)}
|
||||
placeholder="أدخل رقم الهاتف"
|
||||
error={errors.phone}
|
||||
disabled={isLoading}
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
البريد الإلكتروني
|
||||
</label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||
placeholder="أدخل البريد الإلكتروني"
|
||||
error={errors.email}
|
||||
disabled={isLoading}
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div>
|
||||
<label htmlFor="address" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
العنوان
|
||||
</label>
|
||||
<textarea
|
||||
id="address"
|
||||
name="address"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleInputChange("address", e.target.value)}
|
||||
placeholder="أدخل عنوان العميل"
|
||||
rows={3}
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
w-full px-3 py-2 border rounded-lg shadow-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||
disabled:bg-gray-50 disabled:text-gray-500
|
||||
${errors.address
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
{errors.address && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.address}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<Flex justify="end" className="pt-4 gap-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
className="w-20"
|
||||
>
|
||||
إلغاء
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !formData.name.trim()}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isLoading
|
||||
? (isEditing ? "جاري التحديث..." : "جاري الإنشاء...")
|
||||
: (isEditing ? "تحديث العميل" : "إنشاء العميل")
|
||||
}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
282
app/components/customers/CustomerList.tsx
Normal file
282
app/components/customers/CustomerList.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Flex } from "~/components/layout/Flex";
|
||||
import { useSettings } from "~/contexts/SettingsContext";
|
||||
import type { CustomerWithVehicles } from "~/types/database";
|
||||
|
||||
interface CustomerListProps {
|
||||
customers: CustomerWithVehicles[];
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onViewCustomer: (customer: CustomerWithVehicles) => void;
|
||||
onEditCustomer: (customer: CustomerWithVehicles) => void;
|
||||
isLoading: boolean;
|
||||
actionData?: any;
|
||||
}
|
||||
|
||||
export function CustomerList({
|
||||
customers,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
onViewCustomer,
|
||||
onEditCustomer,
|
||||
isLoading,
|
||||
actionData,
|
||||
}: CustomerListProps) {
|
||||
const { formatDate } = useSettings();
|
||||
const [deletingCustomerId, setDeletingCustomerId] = useState<number | null>(null);
|
||||
|
||||
// Reset deleting state when delete action completes
|
||||
useEffect(() => {
|
||||
if (actionData?.success && actionData.action === "delete") {
|
||||
setDeletingCustomerId(null);
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "name",
|
||||
header: "اسم العميل",
|
||||
render: (customer: CustomerWithVehicles) => (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{customer.name}</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{/* العميل رقم: {customer.id} */}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "contact",
|
||||
header: "معلومات الاتصال",
|
||||
render: (customer: CustomerWithVehicles) => (
|
||||
<div className="space-y-1">
|
||||
{customer.phone && (
|
||||
<div className="text-sm text-gray-900" dir="ltr">
|
||||
📞 {customer.phone}
|
||||
</div>
|
||||
)}
|
||||
{customer.email && (
|
||||
<div className="text-sm text-gray-600" dir="ltr">
|
||||
✉️ {customer.email}
|
||||
</div>
|
||||
)}
|
||||
{!customer.phone && !customer.email && (
|
||||
<div className="text-sm text-gray-400">
|
||||
لا توجد معلومات اتصال
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "address",
|
||||
header: "العنوان",
|
||||
render: (customer: CustomerWithVehicles) => (
|
||||
<div className="text-sm text-gray-900">
|
||||
{customer.address || (
|
||||
<span className="text-gray-400">غير محدد</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "vehicles",
|
||||
header: "المركبات",
|
||||
render: (customer: CustomerWithVehicles) => (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{customer.vehicles.length} مركبة
|
||||
</div>
|
||||
{customer.vehicles.length > 0 && (
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
{customer.vehicles.slice(0, 2).map((vehicle) => (
|
||||
<div key={vehicle.id}>
|
||||
{vehicle.plateNumber} - {vehicle.manufacturer} {vehicle.model}
|
||||
</div>
|
||||
))}
|
||||
{customer.vehicles.length > 2 && (
|
||||
<div className="text-gray-400">
|
||||
و {customer.vehicles.length - 2} مركبة أخرى...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "visits",
|
||||
header: "الزيارات",
|
||||
render: (customer: CustomerWithVehicles) => (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{customer.maintenanceVisits.length} زيارة
|
||||
</div>
|
||||
{customer.maintenanceVisits.length > 0 && (
|
||||
<div className="text-sm text-gray-500">
|
||||
آخر زيارة: {formatDate(customer.maintenanceVisits[0].visitDate)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "createdDate",
|
||||
header: "تاريخ الإنشاء",
|
||||
render: (customer: CustomerWithVehicles) => (
|
||||
<div className="text-sm text-gray-600">
|
||||
{formatDate(customer.createdDate)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "الإجراءات",
|
||||
render: (customer: CustomerWithVehicles) => (
|
||||
<Flex className="flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="bg-blue-50 text-blue-600 border-blue-300 hover:bg-blue-100"
|
||||
disabled={isLoading}
|
||||
onClick={() => onViewCustomer(customer)}
|
||||
>
|
||||
عرض
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onEditCustomer(customer)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
تعديل
|
||||
</Button>
|
||||
|
||||
<Form method="post" className="inline">
|
||||
<input type="hidden" name="_action" value="delete" />
|
||||
<input type="hidden" name="id" value={customer.id} />
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-red-600 border-red-300 hover:bg-red-50"
|
||||
disabled={isLoading || deletingCustomerId === customer.id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (window.confirm("هل أنت متأكد من حذف هذا العميل؟")) {
|
||||
setDeletingCustomerId(customer.id);
|
||||
(e.target as HTMLButtonElement).form?.submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deletingCustomerId === customer.id ? "جاري الحذف..." : "حذف"}
|
||||
</Button>
|
||||
</Form>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border">
|
||||
{customers.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 text-lg mb-2">👥</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
لا يوجد عملاء
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
لم يتم العثور على أي عملاء. قم بإضافة عميل جديد للبدء.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{column.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{customers.map((customer) => (
|
||||
<tr key={customer.id} className="hover:bg-gray-50">
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={`${customer.id}-${column.key}`}
|
||||
className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"
|
||||
>
|
||||
{column.render ? column.render(customer) : String(customer[column.key as keyof CustomerWithVehicles] || '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 space-x-reverse">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1 || isLoading}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
السابق
|
||||
</button>
|
||||
|
||||
<div className="flex items-center space-x-1 space-x-reverse">
|
||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
const page = i + 1;
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
disabled={isLoading}
|
||||
className={`px-3 py-2 text-sm font-medium rounded-md ${
|
||||
currentPage === page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-500 bg-white border border-gray-300 hover:bg-gray-50'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages || isLoading}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
التالي
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500">
|
||||
صفحة {currentPage} من {totalPages}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
app/components/expenses/ExpenseForm.tsx
Normal file
195
app/components/expenses/ExpenseForm.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Input } from "~/components/ui/Input";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Flex } from "~/components/layout/Flex";
|
||||
import { EXPENSE_CATEGORIES } from "~/lib/constants";
|
||||
import type { Expense } from "@prisma/client";
|
||||
|
||||
interface ExpenseFormProps {
|
||||
expense?: Expense;
|
||||
onCancel: () => void;
|
||||
errors?: Record<string, string>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function ExpenseForm({
|
||||
expense,
|
||||
onCancel,
|
||||
errors = {},
|
||||
isLoading,
|
||||
}: ExpenseFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
description: expense?.description || "",
|
||||
category: expense?.category || "",
|
||||
amount: expense?.amount?.toString() || "",
|
||||
expenseDate: expense?.expenseDate
|
||||
? new Date(expense.expenseDate).toISOString().split('T')[0]
|
||||
: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
|
||||
// Reset form data when expense changes
|
||||
useEffect(() => {
|
||||
if (expense) {
|
||||
setFormData({
|
||||
description: expense.description || "",
|
||||
category: expense.category || "",
|
||||
amount: expense.amount?.toString() || "",
|
||||
expenseDate: expense.expenseDate
|
||||
? new Date(expense.expenseDate).toISOString().split('T')[0]
|
||||
: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
description: "",
|
||||
category: "",
|
||||
amount: "",
|
||||
expenseDate: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
}
|
||||
}, [expense]);
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const isEditing = !!expense;
|
||||
|
||||
return (
|
||||
<Form method="post" className="space-y-6">
|
||||
<input
|
||||
type="hidden"
|
||||
name="_action"
|
||||
value={isEditing ? "update" : "create"}
|
||||
/>
|
||||
{isEditing && (
|
||||
<input type="hidden" name="id" value={expense.id} />
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
وصف المصروف *
|
||||
</label>
|
||||
<Input
|
||||
id="description"
|
||||
name="description"
|
||||
type="text"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange("description", e.target.value)}
|
||||
placeholder="أدخل وصف المصروف"
|
||||
error={errors.description}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
الفئة *
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
name="category"
|
||||
value={formData.category}
|
||||
onChange={(e) => handleInputChange("category", e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
w-full px-3 py-2 border rounded-lg shadow-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||
disabled:bg-gray-50 disabled:text-gray-500
|
||||
${errors.category
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<option value="">اختر الفئة</option>
|
||||
{EXPENSE_CATEGORIES.map((cat) => (
|
||||
<option key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.category && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.category}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div>
|
||||
<label htmlFor="amount" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
المبلغ *
|
||||
</label>
|
||||
<Input
|
||||
id="amount"
|
||||
name="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
value={formData.amount}
|
||||
onChange={(e) => handleInputChange("amount", e.target.value)}
|
||||
placeholder="0.00"
|
||||
error={errors.amount}
|
||||
required
|
||||
disabled={isLoading}
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.amount && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.amount}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expense Date */}
|
||||
<div>
|
||||
<label htmlFor="expenseDate" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
تاريخ المصروف
|
||||
</label>
|
||||
<Input
|
||||
id="expenseDate"
|
||||
name="expenseDate"
|
||||
type="date"
|
||||
value={formData.expenseDate}
|
||||
onChange={(e) => handleInputChange("expenseDate", e.target.value)}
|
||||
error={errors.expenseDate}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.expenseDate && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.expenseDate}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<Flex justify="end" className="pt-4 gap-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
className="w-20"
|
||||
>
|
||||
إلغاء
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !formData.description.trim() || !formData.category || !formData.amount}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isLoading
|
||||
? (isEditing ? "جاري التحديث..." : "جاري الإنشاء...")
|
||||
: (isEditing ? "تحديث المصروف" : "إنشاء المصروف")
|
||||
}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
199
app/components/forms/EnhancedCustomerForm.tsx
Normal file
199
app/components/forms/EnhancedCustomerForm.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Form as RemixForm } from "@remix-run/react";
|
||||
import { Input } from "~/components/ui/Input";
|
||||
import { Textarea } from "~/components/ui/Textarea";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { FormField } from "~/components/ui/FormField";
|
||||
import { Form, FormActions, FormSection, FormGrid } from "~/components/ui/Form";
|
||||
import { useFormValidation } from "~/hooks/useFormValidation";
|
||||
import { customerSchema } from "~/lib/form-validation";
|
||||
import type { Customer } from "~/types/database";
|
||||
|
||||
interface EnhancedCustomerFormProps {
|
||||
customer?: Customer;
|
||||
onCancel: () => void;
|
||||
errors?: Record<string, string>;
|
||||
isLoading: boolean;
|
||||
onSubmit?: (data: any) => void;
|
||||
}
|
||||
|
||||
export function EnhancedCustomerForm({
|
||||
customer,
|
||||
onCancel,
|
||||
errors = {},
|
||||
isLoading,
|
||||
onSubmit,
|
||||
}: EnhancedCustomerFormProps) {
|
||||
const {
|
||||
values,
|
||||
errors: validationErrors,
|
||||
touched,
|
||||
isValid,
|
||||
setValue,
|
||||
setTouched,
|
||||
reset,
|
||||
validate,
|
||||
getFieldProps,
|
||||
} = useFormValidation({
|
||||
schema: customerSchema,
|
||||
initialValues: {
|
||||
name: customer?.name || "",
|
||||
phone: customer?.phone || "",
|
||||
email: customer?.email || "",
|
||||
address: customer?.address || "",
|
||||
},
|
||||
validateOnChange: true,
|
||||
validateOnBlur: true,
|
||||
});
|
||||
|
||||
// Reset form when customer changes
|
||||
useEffect(() => {
|
||||
if (customer) {
|
||||
reset({
|
||||
name: customer.name || "",
|
||||
phone: customer.phone || "",
|
||||
email: customer.email || "",
|
||||
address: customer.address || "",
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
name: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
address: "",
|
||||
});
|
||||
}
|
||||
}, [customer, reset]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { isValid: formIsValid } = validate();
|
||||
|
||||
if (formIsValid && onSubmit) {
|
||||
onSubmit(values);
|
||||
}
|
||||
};
|
||||
|
||||
const isEditing = !!customer;
|
||||
const combinedErrors = { ...validationErrors, ...errors };
|
||||
|
||||
return (
|
||||
<Form
|
||||
title={isEditing ? "تعديل بيانات العميل" : "إضافة عميل جديد"}
|
||||
description={isEditing ? "قم بتعديل بيانات العميل أدناه" : "أدخل بيانات العميل الجديد"}
|
||||
loading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="_action"
|
||||
value={isEditing ? "update" : "create"}
|
||||
/>
|
||||
{isEditing && (
|
||||
<input type="hidden" name="id" value={customer.id} />
|
||||
)}
|
||||
|
||||
<FormSection
|
||||
title="المعلومات الأساسية"
|
||||
description="البيانات الأساسية للعميل"
|
||||
>
|
||||
<FormGrid columns={2}>
|
||||
{/* Customer Name */}
|
||||
<FormField
|
||||
label="اسم العميل"
|
||||
required
|
||||
error={combinedErrors.name}
|
||||
htmlFor="name"
|
||||
>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
placeholder="أدخل اسم العميل"
|
||||
disabled={isLoading}
|
||||
{...getFieldProps('name')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Phone Number */}
|
||||
<FormField
|
||||
label="رقم الهاتف"
|
||||
error={combinedErrors.phone}
|
||||
htmlFor="phone"
|
||||
helperText="رقم الهاتف اختياري"
|
||||
>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
placeholder="أدخل رقم الهاتف"
|
||||
disabled={isLoading}
|
||||
dir="ltr"
|
||||
{...getFieldProps('phone')}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="معلومات الاتصال"
|
||||
description="بيانات الاتصال الإضافية"
|
||||
>
|
||||
{/* Email */}
|
||||
<FormField
|
||||
label="البريد الإلكتروني"
|
||||
error={combinedErrors.email}
|
||||
htmlFor="email"
|
||||
helperText="البريد الإلكتروني اختياري"
|
||||
>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="أدخل البريد الإلكتروني"
|
||||
disabled={isLoading}
|
||||
dir="ltr"
|
||||
{...getFieldProps('email')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Address */}
|
||||
<FormField
|
||||
label="العنوان"
|
||||
error={combinedErrors.address}
|
||||
htmlFor="address"
|
||||
helperText="عنوان العميل اختياري"
|
||||
>
|
||||
<Textarea
|
||||
id="address"
|
||||
name="address"
|
||||
placeholder="أدخل عنوان العميل"
|
||||
rows={3}
|
||||
disabled={isLoading}
|
||||
{...getFieldProps('address')}
|
||||
/>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
|
||||
<FormActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
إلغاء
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !isValid || !values.name?.trim()}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isEditing ? "تحديث العميل" : "إنشاء العميل"}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
400
app/components/forms/EnhancedVehicleForm.tsx
Normal file
400
app/components/forms/EnhancedVehicleForm.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Form as RemixForm } from "@remix-run/react";
|
||||
import { Input } from "~/components/ui/Input";
|
||||
import { Select } from "~/components/ui/Select";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { FormField } from "~/components/ui/FormField";
|
||||
import { Form, FormActions, FormSection, FormGrid } from "~/components/ui/Form";
|
||||
import { useFormValidation } from "~/hooks/useFormValidation";
|
||||
import { vehicleSchema } from "~/lib/form-validation";
|
||||
import { TRANSMISSION_TYPES, FUEL_TYPES, USE_TYPES, BODY_TYPES, MANUFACTURERS, VALIDATION } from "~/lib/constants";
|
||||
import type { Vehicle } from "~/types/database";
|
||||
|
||||
interface EnhancedVehicleFormProps {
|
||||
vehicle?: Vehicle;
|
||||
customers: { id: number; name: string; phone?: string | null }[];
|
||||
onCancel: () => void;
|
||||
errors?: Record<string, string>;
|
||||
isLoading: boolean;
|
||||
onSubmit?: (data: any) => void;
|
||||
}
|
||||
|
||||
export function EnhancedVehicleForm({
|
||||
vehicle,
|
||||
customers,
|
||||
onCancel,
|
||||
errors = {},
|
||||
isLoading,
|
||||
onSubmit,
|
||||
}: EnhancedVehicleFormProps) {
|
||||
const {
|
||||
values,
|
||||
errors: validationErrors,
|
||||
touched,
|
||||
isValid,
|
||||
setValue,
|
||||
setTouched,
|
||||
reset,
|
||||
validate,
|
||||
getFieldProps,
|
||||
} = useFormValidation({
|
||||
schema: vehicleSchema,
|
||||
initialValues: {
|
||||
plateNumber: vehicle?.plateNumber || "",
|
||||
bodyType: vehicle?.bodyType || "",
|
||||
manufacturer: vehicle?.manufacturer || "",
|
||||
model: vehicle?.model || "",
|
||||
trim: vehicle?.trim || "",
|
||||
year: vehicle?.year || new Date().getFullYear(),
|
||||
transmission: vehicle?.transmission || "",
|
||||
fuel: vehicle?.fuel || "",
|
||||
cylinders: vehicle?.cylinders || null,
|
||||
engineDisplacement: vehicle?.engineDisplacement || null,
|
||||
useType: vehicle?.useType || "",
|
||||
ownerId: vehicle?.ownerId || 0,
|
||||
},
|
||||
validateOnChange: true,
|
||||
validateOnBlur: true,
|
||||
});
|
||||
|
||||
// Reset form when vehicle changes
|
||||
useEffect(() => {
|
||||
if (vehicle) {
|
||||
reset({
|
||||
plateNumber: vehicle.plateNumber || "",
|
||||
bodyType: vehicle.bodyType || "",
|
||||
manufacturer: vehicle.manufacturer || "",
|
||||
model: vehicle.model || "",
|
||||
trim: vehicle.trim || "",
|
||||
year: vehicle.year || new Date().getFullYear(),
|
||||
transmission: vehicle.transmission || "",
|
||||
fuel: vehicle.fuel || "",
|
||||
cylinders: vehicle.cylinders || null,
|
||||
engineDisplacement: vehicle.engineDisplacement || null,
|
||||
useType: vehicle.useType || "",
|
||||
ownerId: vehicle.ownerId || 0,
|
||||
});
|
||||
} else {
|
||||
reset({
|
||||
plateNumber: "",
|
||||
bodyType: "",
|
||||
manufacturer: "",
|
||||
model: "",
|
||||
trim: "",
|
||||
year: new Date().getFullYear(),
|
||||
transmission: "",
|
||||
fuel: "",
|
||||
cylinders: null,
|
||||
engineDisplacement: null,
|
||||
useType: "",
|
||||
ownerId: 0,
|
||||
});
|
||||
}
|
||||
}, [vehicle, reset]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { isValid: formIsValid } = validate();
|
||||
|
||||
if (formIsValid && onSubmit) {
|
||||
onSubmit(values);
|
||||
}
|
||||
};
|
||||
|
||||
const isEditing = !!vehicle;
|
||||
const combinedErrors = { ...validationErrors, ...errors };
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<Form
|
||||
title={isEditing ? "تعديل بيانات المركبة" : "إضافة مركبة جديدة"}
|
||||
description={isEditing ? "قم بتعديل بيانات المركبة أدناه" : "أدخل بيانات المركبة الجديدة"}
|
||||
loading={isLoading}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="_action"
|
||||
value={isEditing ? "update" : "create"}
|
||||
/>
|
||||
{isEditing && (
|
||||
<input type="hidden" name="id" value={vehicle.id} />
|
||||
)}
|
||||
|
||||
<FormSection
|
||||
title="المعلومات الأساسية"
|
||||
description="البيانات الأساسية للمركبة"
|
||||
>
|
||||
<FormGrid columns={2}>
|
||||
{/* Plate Number */}
|
||||
<FormField
|
||||
label="رقم اللوحة"
|
||||
required
|
||||
error={combinedErrors.plateNumber}
|
||||
htmlFor="plateNumber"
|
||||
>
|
||||
<Input
|
||||
id="plateNumber"
|
||||
name="plateNumber"
|
||||
type="text"
|
||||
placeholder="أدخل رقم اللوحة"
|
||||
disabled={isLoading}
|
||||
dir="ltr"
|
||||
{...getFieldProps('plateNumber')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Owner */}
|
||||
<FormField
|
||||
label="المالك"
|
||||
required
|
||||
error={combinedErrors.ownerId}
|
||||
htmlFor="ownerId"
|
||||
>
|
||||
<Select
|
||||
id="ownerId"
|
||||
name="ownerId"
|
||||
placeholder="اختر المالك"
|
||||
disabled={isLoading}
|
||||
options={customers.map(customer => ({
|
||||
value: customer.id.toString(),
|
||||
label: `${customer.name}${customer.phone ? ` (${customer.phone})` : ''}`,
|
||||
}))}
|
||||
value={values.ownerId?.toString() || ''}
|
||||
onChange={(e) => setValue('ownerId', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="مواصفات المركبة"
|
||||
description="التفاصيل التقنية للمركبة"
|
||||
>
|
||||
<FormGrid columns={3}>
|
||||
{/* Body Type */}
|
||||
<FormField
|
||||
label="نوع الهيكل"
|
||||
required
|
||||
error={combinedErrors.bodyType}
|
||||
htmlFor="bodyType"
|
||||
>
|
||||
<Select
|
||||
id="bodyType"
|
||||
name="bodyType"
|
||||
placeholder="اختر نوع الهيكل"
|
||||
aria-readonly={isLoading}
|
||||
options={BODY_TYPES.map(type => ({
|
||||
value: type.value,
|
||||
label: type.label,
|
||||
}))}
|
||||
{...getFieldProps('bodyType')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Manufacturer */}
|
||||
<FormField
|
||||
label="الشركة المصنعة"
|
||||
required
|
||||
error={combinedErrors.manufacturer}
|
||||
htmlFor="manufacturer"
|
||||
>
|
||||
<Select
|
||||
id="manufacturer"
|
||||
name="manufacturer"
|
||||
placeholder="اختر الشركة المصنعة"
|
||||
disabled={isLoading}
|
||||
options={MANUFACTURERS.map(manufacturer => ({
|
||||
value: manufacturer.value,
|
||||
label: manufacturer.label,
|
||||
}))}
|
||||
{...getFieldProps('manufacturer')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Model */}
|
||||
<FormField
|
||||
label="الموديل"
|
||||
required
|
||||
error={combinedErrors.model}
|
||||
htmlFor="model"
|
||||
>
|
||||
<Input
|
||||
id="model"
|
||||
name="model"
|
||||
type="text"
|
||||
placeholder="أدخل الموديل"
|
||||
disabled={isLoading}
|
||||
{...getFieldProps('model')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Trim */}
|
||||
<FormField
|
||||
label="الفئة"
|
||||
error={combinedErrors.trim}
|
||||
htmlFor="trim"
|
||||
helperText="الفئة اختيارية"
|
||||
>
|
||||
<Input
|
||||
id="trim"
|
||||
name="trim"
|
||||
type="text"
|
||||
placeholder="أدخل الفئة (اختياري)"
|
||||
disabled={isLoading}
|
||||
{...getFieldProps('trim')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Year */}
|
||||
<FormField
|
||||
label="سنة الصنع"
|
||||
required
|
||||
error={combinedErrors.year}
|
||||
htmlFor="year"
|
||||
>
|
||||
<Input
|
||||
id="year"
|
||||
name="year"
|
||||
type="number"
|
||||
min={VALIDATION.MIN_YEAR}
|
||||
max={VALIDATION.MAX_YEAR}
|
||||
placeholder={`${VALIDATION.MIN_YEAR} - ${currentYear}`}
|
||||
disabled={isLoading}
|
||||
value={values.year?.toString() || ''}
|
||||
onChange={(e) => setValue('year', parseInt(e.target.value) || currentYear)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Use Type */}
|
||||
<FormField
|
||||
label="نوع الاستخدام"
|
||||
required
|
||||
error={combinedErrors.useType}
|
||||
htmlFor="useType"
|
||||
>
|
||||
<Select
|
||||
id="useType"
|
||||
name="useType"
|
||||
placeholder="اختر نوع الاستخدام"
|
||||
disabled={isLoading}
|
||||
options={USE_TYPES.map(useType => ({
|
||||
value: useType.value,
|
||||
label: useType.label,
|
||||
}))}
|
||||
{...getFieldProps('useType')}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="المحرك والناقل"
|
||||
description="مواصفات المحرك وناقل الحركة"
|
||||
>
|
||||
<FormGrid columns={2}>
|
||||
{/* Transmission */}
|
||||
<FormField
|
||||
label="ناقل الحركة"
|
||||
required
|
||||
error={combinedErrors.transmission}
|
||||
htmlFor="transmission"
|
||||
>
|
||||
<Select
|
||||
id="transmission"
|
||||
name="transmission"
|
||||
placeholder="اختر ناقل الحركة"
|
||||
disabled={isLoading}
|
||||
options={TRANSMISSION_TYPES.map(transmission => ({
|
||||
value: transmission.value,
|
||||
label: transmission.label,
|
||||
}))}
|
||||
{...getFieldProps('transmission')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Fuel */}
|
||||
<FormField
|
||||
label="نوع الوقود"
|
||||
required
|
||||
error={combinedErrors.fuel}
|
||||
htmlFor="fuel"
|
||||
>
|
||||
<Select
|
||||
id="fuel"
|
||||
name="fuel"
|
||||
placeholder="اختر نوع الوقود"
|
||||
disabled={isLoading}
|
||||
options={FUEL_TYPES.map(fuel => ({
|
||||
value: fuel.value,
|
||||
label: fuel.label,
|
||||
}))}
|
||||
{...getFieldProps('fuel')}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Cylinders */}
|
||||
<FormField
|
||||
label="عدد الأسطوانات"
|
||||
error={combinedErrors.cylinders}
|
||||
htmlFor="cylinders"
|
||||
helperText="عدد الأسطوانات اختياري"
|
||||
>
|
||||
<Input
|
||||
id="cylinders"
|
||||
name="cylinders"
|
||||
type="number"
|
||||
min="1"
|
||||
max={VALIDATION.MAX_CYLINDERS}
|
||||
placeholder="عدد الأسطوانات (اختياري)"
|
||||
disabled={isLoading}
|
||||
value={values.cylinders?.toString() || ''}
|
||||
onChange={(e) => setValue('cylinders', e.target.value ? parseInt(e.target.value) : null)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{/* Engine Displacement */}
|
||||
<FormField
|
||||
label="سعة المحرك (لتر)"
|
||||
error={combinedErrors.engineDisplacement}
|
||||
htmlFor="engineDisplacement"
|
||||
helperText="سعة المحرك اختيارية"
|
||||
>
|
||||
<Input
|
||||
id="engineDisplacement"
|
||||
name="engineDisplacement"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
max={VALIDATION.MAX_ENGINE_DISPLACEMENT}
|
||||
placeholder="سعة المحرك (اختياري)"
|
||||
disabled={isLoading}
|
||||
value={values.engineDisplacement?.toString() || ''}
|
||||
onChange={(e) => setValue('engineDisplacement', e.target.value ? parseFloat(e.target.value) : null)}
|
||||
/>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
إلغاء
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !isValid || !values.plateNumber?.trim() || !values.ownerId}
|
||||
loading={isLoading}
|
||||
>
|
||||
{isEditing ? "تحديث المركبة" : "إنشاء المركبة"}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
41
app/components/layout/Container.tsx
Normal file
41
app/components/layout/Container.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { getResponsiveClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface ContainerProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
|
||||
padding?: boolean;
|
||||
}
|
||||
|
||||
export function Container({
|
||||
children,
|
||||
className = '',
|
||||
config = {},
|
||||
maxWidth = 'full',
|
||||
padding = true
|
||||
}: ContainerProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
const classes = getResponsiveClasses(layoutConfig);
|
||||
|
||||
const maxWidthClasses = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
xl: 'max-w-xl',
|
||||
'2xl': 'max-w-2xl',
|
||||
full: 'max-w-full',
|
||||
};
|
||||
|
||||
const paddingClass = padding ? 'px-4 sm:px-6 lg:px-8' : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${classes.container} ${maxWidthClasses[maxWidth]} ${paddingClass} ${className}`}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
app/components/layout/DashboardLayout.tsx
Normal file
170
app/components/layout/DashboardLayout.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { ReactNode, useState, useEffect } from 'react';
|
||||
import { Form } from '@remix-run/react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Container } from './Container';
|
||||
import { Flex } from './Flex';
|
||||
import { Text, Button } from '../ui';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
children: ReactNode;
|
||||
user: {
|
||||
id: number;
|
||||
name: string;
|
||||
authLevel: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function DashboardLayout({ children, user }: DashboardLayoutProps) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
// Handle responsive behavior
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
const mobile = window.innerWidth < 768;
|
||||
setIsMobile(mobile);
|
||||
|
||||
// Auto-collapse sidebar on mobile
|
||||
if (mobile) {
|
||||
setSidebarCollapsed(true);
|
||||
}
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
// Load sidebar state from localStorage
|
||||
useEffect(() => {
|
||||
const savedState = localStorage.getItem('sidebarCollapsed');
|
||||
if (savedState !== null && !isMobile) {
|
||||
setSidebarCollapsed(JSON.parse(savedState));
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
// Save sidebar state to localStorage
|
||||
const handleSidebarToggle = () => {
|
||||
const newState = !sidebarCollapsed;
|
||||
setSidebarCollapsed(newState);
|
||||
if (!isMobile) {
|
||||
localStorage.setItem('sidebarCollapsed', JSON.stringify(newState));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMobileMenuClose = () => {
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const getAuthLevelText = (authLevel: number) => {
|
||||
switch (authLevel) {
|
||||
case 1:
|
||||
return "مدير عام";
|
||||
case 2:
|
||||
return "مدير";
|
||||
case 3:
|
||||
return "مستخدم";
|
||||
default:
|
||||
return "غير محدد";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex" dir="rtl">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
isCollapsed={sidebarCollapsed}
|
||||
onToggle={handleSidebarToggle}
|
||||
isMobile={isMobile}
|
||||
isOpen={mobileMenuOpen}
|
||||
onClose={handleMobileMenuClose}
|
||||
userAuthLevel={user.authLevel}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className={`
|
||||
flex-1 min-h-screen transition-all duration-300 ease-in-out
|
||||
${!isMobile ? (sidebarCollapsed ? 'mr-16' : 'mr-64') : 'mr-0'}
|
||||
`}>
|
||||
{/* Header */}
|
||||
<div className="bg-white shadow-sm border-b border-gray-200 sticky top-0 z-10">
|
||||
<div className="max-w-full mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-4">
|
||||
{/* Mobile menu button and title */}
|
||||
<div className="flex items-center gap-4">
|
||||
{isMobile && (
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Page title - only show on mobile when sidebar is closed */}
|
||||
{isMobile && (
|
||||
<h1 className="text-lg font-semibold text-gray-900">
|
||||
لوحة التحكم
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User info and actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-600">
|
||||
مرحباً، <span className="font-medium text-gray-900">{user.name}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{getAuthLevelText(user.authLevel)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form action="/logout" method="post">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors duration-150"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 mr-1"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
خروج
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page Content */}
|
||||
<main className="flex-1 p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
app/components/layout/Flex.tsx
Normal file
102
app/components/layout/Flex.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { getResponsiveClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface FlexProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
direction?: 'row' | 'col' | 'row-reverse' | 'col-reverse';
|
||||
align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline';
|
||||
justify?: 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
|
||||
wrap?: boolean;
|
||||
gap?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
responsive?: {
|
||||
sm?: Partial<Pick<FlexProps, 'direction' | 'align' | 'justify'>>;
|
||||
md?: Partial<Pick<FlexProps, 'direction' | 'align' | 'justify'>>;
|
||||
lg?: Partial<Pick<FlexProps, 'direction' | 'align' | 'justify'>>;
|
||||
xl?: Partial<Pick<FlexProps, 'direction' | 'align' | 'justify'>>;
|
||||
};
|
||||
}
|
||||
|
||||
export function Flex({
|
||||
children,
|
||||
className = '',
|
||||
config = {},
|
||||
direction = 'row',
|
||||
align = 'start',
|
||||
justify = 'start',
|
||||
wrap = false,
|
||||
gap = 'md',
|
||||
responsive = {}
|
||||
}: FlexProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
const classes = getResponsiveClasses(layoutConfig);
|
||||
|
||||
const directionClasses = {
|
||||
row: layoutConfig.direction === 'rtl' ? 'flex-row-reverse' : 'flex-row',
|
||||
col: 'flex-col',
|
||||
'row-reverse': layoutConfig.direction === 'rtl' ? 'flex-row' : 'flex-row-reverse',
|
||||
'col-reverse': 'flex-col-reverse',
|
||||
};
|
||||
|
||||
const alignClasses = {
|
||||
start: 'items-start',
|
||||
center: 'items-center',
|
||||
end: 'items-end',
|
||||
stretch: 'items-stretch',
|
||||
baseline: 'items-baseline',
|
||||
};
|
||||
|
||||
const justifyClasses = {
|
||||
start: 'justify-start',
|
||||
center: 'justify-center',
|
||||
end: 'justify-end',
|
||||
between: 'justify-between',
|
||||
around: 'justify-around',
|
||||
evenly: 'justify-evenly',
|
||||
};
|
||||
|
||||
const gapClasses = {
|
||||
sm: 'gap-2',
|
||||
md: 'gap-4',
|
||||
lg: 'gap-6',
|
||||
xl: 'gap-8',
|
||||
};
|
||||
|
||||
const wrapClass = wrap ? 'flex-wrap' : '';
|
||||
|
||||
// Build responsive classes
|
||||
const responsiveClasses = Object.entries(responsive)
|
||||
.map(([breakpoint, props]) => {
|
||||
const responsiveClassList = [];
|
||||
|
||||
if (props.direction) {
|
||||
const responsiveDirection = props.direction === 'row' && layoutConfig.direction === 'rtl'
|
||||
? 'flex-row-reverse'
|
||||
: props.direction === 'row-reverse' && layoutConfig.direction === 'rtl'
|
||||
? 'flex-row'
|
||||
: directionClasses[props.direction];
|
||||
responsiveClassList.push(`${breakpoint}:${responsiveDirection}`);
|
||||
}
|
||||
|
||||
if (props.align) {
|
||||
responsiveClassList.push(`${breakpoint}:${alignClasses[props.align]}`);
|
||||
}
|
||||
|
||||
if (props.justify) {
|
||||
responsiveClassList.push(`${breakpoint}:${justifyClasses[props.justify]}`);
|
||||
}
|
||||
|
||||
return responsiveClassList.join(' ');
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${directionClasses[direction]} ${alignClasses[align]} ${justifyClasses[justify]} ${gapClasses[gap]} ${wrapClass} ${responsiveClasses} ${className}`}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
app/components/layout/Grid.tsx
Normal file
56
app/components/layout/Grid.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface GridProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
cols?: 1 | 2 | 3 | 4 | 6 | 12;
|
||||
gap?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
responsive?: {
|
||||
sm?: 1 | 2 | 3 | 4 | 6 | 12;
|
||||
md?: 1 | 2 | 3 | 4 | 6 | 12;
|
||||
lg?: 1 | 2 | 3 | 4 | 6 | 12;
|
||||
xl?: 1 | 2 | 3 | 4 | 6 | 12;
|
||||
};
|
||||
}
|
||||
|
||||
export function Grid({
|
||||
children,
|
||||
className = '',
|
||||
config = {},
|
||||
cols = 1,
|
||||
gap = 'md',
|
||||
responsive = {}
|
||||
}: GridProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
const colsClasses = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-2',
|
||||
3: 'grid-cols-3',
|
||||
4: 'grid-cols-4',
|
||||
6: 'grid-cols-6',
|
||||
12: 'grid-cols-12',
|
||||
};
|
||||
|
||||
const gapClasses = {
|
||||
sm: 'gap-2',
|
||||
md: 'gap-4',
|
||||
lg: 'gap-6',
|
||||
xl: 'gap-8',
|
||||
};
|
||||
|
||||
const responsiveClasses = Object.entries(responsive)
|
||||
.map(([breakpoint, cols]) => `${breakpoint}:${colsClasses[cols]}`)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid-rtl ${colsClasses[cols]} ${gapClasses[gap]} ${responsiveClasses} ${className}`}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
257
app/components/layout/Sidebar.tsx
Normal file
257
app/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { ReactNode, useState, useEffect } from 'react';
|
||||
import { Link, useLocation } from '@remix-run/react';
|
||||
import { getResponsiveClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface SidebarProps {
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
isMobile: boolean;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
userAuthLevel: number;
|
||||
}
|
||||
|
||||
interface NavigationItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: ReactNode;
|
||||
authLevel?: number; // Minimum auth level required
|
||||
}
|
||||
|
||||
const navigationItems: NavigationItem[] = [
|
||||
{
|
||||
name: 'لوحة التحكم',
|
||||
href: '/dashboard',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 5a2 2 0 012-2h4a2 2 0 012 2v6H8V5z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'العملاء',
|
||||
href: '/customers',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'المركبات',
|
||||
href: '/vehicles',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6 0a1 1 0 001 1h4a1 1 0 001-1m-6 0V9a1 1 0 00-1-1v0a1 1 0 00-1 1v8a1 1 0 001 1z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'زيارات الصيانة',
|
||||
href: '/maintenance-visits',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'المصروفات',
|
||||
href: '/expenses',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
authLevel: 2, // Admin and above
|
||||
},
|
||||
{
|
||||
name: 'التقارير المالية',
|
||||
href: '/financial-reports',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
),
|
||||
authLevel: 2, // Admin and above
|
||||
},
|
||||
{
|
||||
name: 'إدارة المستخدمين',
|
||||
href: '/users',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
|
||||
</svg>
|
||||
),
|
||||
authLevel: 2, // Admin and above
|
||||
},
|
||||
{
|
||||
name: 'إعدادات النظام',
|
||||
href: '/settings',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
authLevel: 2, // Admin and above
|
||||
},
|
||||
];
|
||||
|
||||
export function Sidebar({ isCollapsed, onToggle, isMobile, isOpen, onClose, userAuthLevel }: SidebarProps) {
|
||||
const location = useLocation();
|
||||
|
||||
// Filter navigation items based on user auth level
|
||||
const filteredNavItems = navigationItems.filter(item =>
|
||||
!item.authLevel || userAuthLevel <= item.authLevel
|
||||
);
|
||||
|
||||
// Close sidebar on route change for mobile
|
||||
useEffect(() => {
|
||||
if (isMobile && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}, [location.pathname, isMobile, isOpen, onClose]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity duration-300"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Sidebar */}
|
||||
<div className={`
|
||||
fixed top-0 right-0 h-full w-64 bg-white shadow-lg z-50 transform transition-transform duration-300 ease-in-out
|
||||
${isOpen ? 'translate-x-0' : 'translate-x-full'}
|
||||
`} dir="rtl">
|
||||
{/* Mobile Header */}
|
||||
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="ml-3 text-lg font-semibold text-gray-900">نظام الصيانة</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<nav className="mt-5 px-2 flex-1 overflow-y-auto">
|
||||
<div className="space-y-1">
|
||||
{filteredNavItems.map((item) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`
|
||||
group flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-150 relative
|
||||
${isActive ? 'bg-blue-100 text-blue-900' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}
|
||||
`}
|
||||
>
|
||||
{isActive && <div className="absolute right-0 top-0 bottom-0 w-1 bg-blue-600 rounded-l-md"></div>}
|
||||
<div className={`flex-shrink-0 ${isActive ? 'text-blue-600' : 'text-gray-400 group-hover:text-gray-500'}`}>
|
||||
{item.icon}
|
||||
</div>
|
||||
<span className="ml-3">{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop Sidebar
|
||||
return (
|
||||
<div className={`
|
||||
fixed top-0 right-0 h-full bg-white shadow-lg z-40 transition-all duration-300 ease-in-out
|
||||
${isCollapsed ? 'w-16' : 'w-64'}
|
||||
`} dir="rtl">
|
||||
{/* Desktop Header */}
|
||||
<div className="flex items-center justify-between h-16 px-4 border-b border-gray-200">
|
||||
<div className="flex items-center flex-1">
|
||||
<div className="h-8 w-8 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<svg className="h-5 w-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<h1 className="ml-3 text-lg font-semibold text-gray-900">نظام الصيانة</h1>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-1 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg
|
||||
className={`h-4 w-4 transform transition-transform duration-200 ${isCollapsed ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="mt-5 px-2 flex-1 overflow-y-auto">
|
||||
<div className="space-y-1">
|
||||
{filteredNavItems.map((item) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`
|
||||
group flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-150 relative
|
||||
${isActive ? 'bg-blue-100 text-blue-900' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}
|
||||
${isCollapsed ? 'justify-center' : ''}
|
||||
`}
|
||||
title={isCollapsed ? item.name : undefined}
|
||||
>
|
||||
{isActive && <div className="absolute right-0 top-0 bottom-0 w-1 bg-blue-600 rounded-l-md"></div>}
|
||||
<div className={`flex-shrink-0 ${isActive ? 'text-blue-600' : 'text-gray-400 group-hover:text-gray-500'}`}>
|
||||
{item.icon}
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<span className="ml-3 truncate">{item.name}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Desktop Footer */}
|
||||
{!isCollapsed && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200">
|
||||
<div className="text-xs text-gray-500 text-center">
|
||||
نظام إدارة صيانة السيارات
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
app/components/layout/index.ts
Normal file
5
app/components/layout/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { Container } from './Container';
|
||||
export { Grid } from './Grid';
|
||||
export { Flex } from './Flex';
|
||||
export { Sidebar } from './Sidebar';
|
||||
export { DashboardLayout } from './DashboardLayout';
|
||||
@@ -0,0 +1,311 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { useSettings } from "~/contexts/SettingsContext";
|
||||
import type { MaintenanceVisitWithRelations } from "~/types/database";
|
||||
|
||||
interface MaintenanceVisitDetailsViewProps {
|
||||
visit: MaintenanceVisitWithRelations;
|
||||
}
|
||||
|
||||
export function MaintenanceVisitDetailsView({ visit }: MaintenanceVisitDetailsViewProps) {
|
||||
const { formatCurrency, formatDate, formatNumber } = useSettings();
|
||||
const getPaymentStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'partial':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getPaymentStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid': return 'مدفوع';
|
||||
case 'pending': return 'معلق';
|
||||
case 'partial': return 'مدفوع جزئياً';
|
||||
case 'cancelled': return 'ملغي';
|
||||
default: return status;
|
||||
}
|
||||
};
|
||||
|
||||
const getDelayLabel = (months: number) => {
|
||||
const delayOptions = [
|
||||
{ value: 1, label: 'شهر واحد' },
|
||||
{ value: 2, label: 'شهرين' },
|
||||
{ value: 3, label: '3 أشهر' },
|
||||
{ value: 4, label: '4 أشهر' },
|
||||
{ value: 6, label: '6 أشهر' },
|
||||
{ value: 12, label: 'سنة واحدة' },
|
||||
];
|
||||
const option = delayOptions.find(opt => opt.value === months);
|
||||
return option ? option.label : `${months} أشهر`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Header Section */}
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-6 border border-blue-100">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<span className="text-3xl ml-3">🔧</span>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
زيارة صيانة #{visit.id}
|
||||
</h2>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{formatDate(visit.visitDate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-blue-600 mb-1">
|
||||
{formatCurrency(visit.cost)}
|
||||
</div>
|
||||
<span className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full border ${getPaymentStatusColor(visit.paymentStatus)}`}>
|
||||
{getPaymentStatusLabel(visit.paymentStatus)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Details Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Visit Information */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<span className="text-gray-600 text-xl ml-2">📋</span>
|
||||
تفاصيل الزيارة
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-3">أعمال الصيانة المنجزة</label>
|
||||
<div className="space-y-3">
|
||||
{(() => {
|
||||
try {
|
||||
const jobs = JSON.parse(visit.maintenanceJobs);
|
||||
return jobs.map((job: any, index: number) => (
|
||||
<div key={index} className="bg-white p-3 rounded-lg border border-blue-200">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-gray-900 mb-1">{job.job}</p>
|
||||
{job.notes && (
|
||||
<p className="text-sm text-gray-600">{job.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded-full">
|
||||
#{index + 1}
|
||||
</span>
|
||||
{job.cost !== undefined && (
|
||||
<span className="text-sm font-bold text-gray-700">
|
||||
{formatCurrency(job.cost)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
} catch {
|
||||
return (
|
||||
<div className="bg-white p-3 rounded-lg border border-blue-200">
|
||||
<p className="text-gray-900">لا توجد تفاصيل أعمال الصيانة</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">عداد الكيلومترات</label>
|
||||
<p className="text-gray-900 font-mono text-lg">
|
||||
{formatNumber(visit.kilometers)} كم
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">الزيارة التالية بعد</label>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{getDelayLabel(visit.nextVisitDelay)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-2">وصف الأعمال المنجزة</label>
|
||||
<div className="bg-white rounded-lg p-4 border">
|
||||
<p className="text-gray-900 whitespace-pre-wrap leading-relaxed">
|
||||
{visit.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vehicle & Customer Information */}
|
||||
<div className="space-y-6">
|
||||
{/* Vehicle Info */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<span className="text-gray-600 text-xl ml-2">🚗</span>
|
||||
معلومات المركبة
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">رقم اللوحة</label>
|
||||
<p className="text-xl font-bold text-gray-900 font-mono">
|
||||
{visit.vehicle.plateNumber}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">السنة</label>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{visit.vehicle.year}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">الشركة المصنعة</label>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{visit.vehicle.manufacturer}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">الموديل</label>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{visit.vehicle.model}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer Info */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<span className="text-gray-600 text-xl ml-2">👤</span>
|
||||
معلومات العميل
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">اسم العميل</label>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{visit.customer.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{(visit.customer.phone || visit.customer.email) && (
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{visit.customer.phone && (
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">رقم الهاتف</label>
|
||||
<a
|
||||
href={`tel:${visit.customer.phone}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-mono font-medium"
|
||||
dir="ltr"
|
||||
>
|
||||
📞 {visit.customer.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visit.customer.email && (
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">البريد الإلكتروني</label>
|
||||
<a
|
||||
href={`mailto:${visit.customer.email}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
dir="ltr"
|
||||
>
|
||||
✉️ {visit.customer.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visit.customer.address && (
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">العنوان</label>
|
||||
<p className="text-gray-900">
|
||||
{visit.customer.address}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Income Information */}
|
||||
{visit.income && visit.income.length > 0 && (
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<span className="text-gray-600 text-xl ml-2">💰</span>
|
||||
سجل الدخل
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-3">
|
||||
{visit.income.map((income) => (
|
||||
<div key={income.id} className="flex justify-between items-center bg-green-50 p-4 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">تاريخ الدخل</p>
|
||||
<p className="font-medium text-gray-900">
|
||||
{formatDate(income.incomeDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-600">المبلغ</p>
|
||||
<p className="text-xl font-bold text-green-600 font-mono">
|
||||
{formatCurrency(income.amount)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-3 pt-6 border-t border-gray-200">
|
||||
<Link
|
||||
to={`/vehicles?search=${encodeURIComponent(visit.vehicle.plateNumber)}`}
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<span className="ml-2">🚗</span>
|
||||
عرض تفاصيل المركبة
|
||||
</Link>
|
||||
<Link
|
||||
to={`/customers?search=${encodeURIComponent(visit.customer.name)}`}
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-green-600 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
|
||||
>
|
||||
<span className="ml-2">👤</span>
|
||||
عرض تفاصيل العميل
|
||||
</Link>
|
||||
<Link
|
||||
to={`/maintenance-visits?vehicleId=${visit.vehicle.id}`}
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-purple-600 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors"
|
||||
>
|
||||
<span className="ml-2">📋</span>
|
||||
جميع زيارات هذه المركبة
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
564
app/components/maintenance-visits/MaintenanceVisitForm.tsx
Normal file
564
app/components/maintenance-visits/MaintenanceVisitForm.tsx
Normal file
@@ -0,0 +1,564 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { Button, Input, Select, Text, Card, CardHeader, CardBody, MultiSelect } from "~/components/ui";
|
||||
import { AutocompleteInput } from "~/components/ui/AutocompleteInput";
|
||||
import { useSettings } from "~/contexts/SettingsContext";
|
||||
import type { MaintenanceVisitWithRelations, Customer, Vehicle, MaintenanceType, MaintenanceJob } from "~/types/database";
|
||||
import { PAYMENT_STATUS_NAMES, VISIT_DELAY_OPTIONS } from "~/lib/constants";
|
||||
|
||||
interface MaintenanceVisitFormProps {
|
||||
visit?: MaintenanceVisitWithRelations;
|
||||
customers: Customer[];
|
||||
vehicles: Vehicle[];
|
||||
maintenanceTypes: { id: number; name: string; }[];
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function MaintenanceVisitForm({
|
||||
visit,
|
||||
customers,
|
||||
vehicles,
|
||||
maintenanceTypes,
|
||||
onCancel
|
||||
}: MaintenanceVisitFormProps) {
|
||||
const actionData = useActionData<any>();
|
||||
const navigation = useNavigation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
const { settings } = useSettings();
|
||||
|
||||
// Form state
|
||||
const [plateNumberInput, setPlateNumberInput] = useState<string>(
|
||||
visit?.vehicle?.plateNumber || ""
|
||||
);
|
||||
const [selectedCustomerId, setSelectedCustomerId] = useState<string>(
|
||||
visit?.customerId?.toString() || ""
|
||||
);
|
||||
const [selectedVehicleId, setSelectedVehicleId] = useState<string>(
|
||||
visit?.vehicleId?.toString() || ""
|
||||
);
|
||||
const [filteredVehicles, setFilteredVehicles] = useState<Vehicle[]>(vehicles);
|
||||
|
||||
// Maintenance jobs state (with costs)
|
||||
const [maintenanceJobs, setMaintenanceJobs] = useState<MaintenanceJob[]>(() => {
|
||||
if (visit?.maintenanceJobs) {
|
||||
try {
|
||||
const jobs = JSON.parse(visit.maintenanceJobs);
|
||||
return jobs;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// Current maintenance type being added
|
||||
const [currentTypeId, setCurrentTypeId] = useState<string>("");
|
||||
const [currentCost, setCurrentCost] = useState<string>("");
|
||||
|
||||
// Create autocomplete options for plate numbers
|
||||
const plateNumberOptions = vehicles.map(vehicle => {
|
||||
const customer = customers.find(c => c.id === vehicle.ownerId);
|
||||
return {
|
||||
value: vehicle.plateNumber,
|
||||
label: `${vehicle.plateNumber} - ${vehicle.manufacturer} ${vehicle.model} (${customer?.name || 'غير محدد'})`,
|
||||
data: {
|
||||
vehicle,
|
||||
customer
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Handle plate number selection
|
||||
const handlePlateNumberSelect = (option: any) => {
|
||||
const { vehicle, customer } = option.data;
|
||||
setPlateNumberInput(vehicle.plateNumber);
|
||||
setSelectedCustomerId(customer?.id?.toString() || "");
|
||||
setSelectedVehicleId(vehicle.id.toString());
|
||||
};
|
||||
|
||||
// Reset form state when visit prop changes (switching between create/edit modes)
|
||||
useEffect(() => {
|
||||
if (visit) {
|
||||
// Editing mode - populate with visit data
|
||||
setPlateNumberInput(visit.vehicle?.plateNumber || "");
|
||||
setSelectedCustomerId(visit.customerId?.toString() || "");
|
||||
setSelectedVehicleId(visit.vehicleId?.toString() || "");
|
||||
|
||||
// Parse maintenance jobs from JSON
|
||||
try {
|
||||
const jobs = JSON.parse(visit.maintenanceJobs);
|
||||
setMaintenanceJobs(jobs);
|
||||
} catch {
|
||||
setMaintenanceJobs([]);
|
||||
}
|
||||
} else {
|
||||
// Create mode - reset to empty state
|
||||
setPlateNumberInput("");
|
||||
setSelectedCustomerId("");
|
||||
setSelectedVehicleId("");
|
||||
setMaintenanceJobs([]);
|
||||
setCurrentTypeId("");
|
||||
setCurrentCost("");
|
||||
}
|
||||
}, [visit]);
|
||||
|
||||
// Filter vehicles based on selected customer
|
||||
useEffect(() => {
|
||||
if (selectedCustomerId) {
|
||||
const customerId = parseInt(selectedCustomerId);
|
||||
const customerVehicles = vehicles.filter(v => v.ownerId === customerId);
|
||||
setFilteredVehicles(customerVehicles);
|
||||
|
||||
// Reset vehicle selection if current vehicle doesn't belong to selected customer
|
||||
if (selectedVehicleId) {
|
||||
const vehicleId = parseInt(selectedVehicleId);
|
||||
const vehicleBelongsToCustomer = customerVehicles.some(v => v.id === vehicleId);
|
||||
if (!vehicleBelongsToCustomer) {
|
||||
setSelectedVehicleId("");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setFilteredVehicles(vehicles);
|
||||
}
|
||||
}, [selectedCustomerId, vehicles, selectedVehicleId]);
|
||||
|
||||
// Format date for input
|
||||
const formatDateForInput = (date: Date | string | null) => {
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
return d.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM format
|
||||
};
|
||||
|
||||
// Calculate total cost from maintenance jobs
|
||||
const totalCost = maintenanceJobs.reduce((sum, job) => sum + job.cost, 0);
|
||||
|
||||
// Add maintenance job
|
||||
const handleAddMaintenanceJob = () => {
|
||||
if (!currentTypeId || !currentCost) return;
|
||||
|
||||
const typeIdNum = parseInt(currentTypeId);
|
||||
const costNum = parseFloat(currentCost);
|
||||
|
||||
if (isNaN(typeIdNum) || isNaN(costNum) || costNum <= 0) return;
|
||||
|
||||
// Check if type already exists
|
||||
if (maintenanceJobs.some(job => job.typeId === typeIdNum)) {
|
||||
alert("هذا النوع من الصيانة موجود بالفعل");
|
||||
return;
|
||||
}
|
||||
|
||||
const type = maintenanceTypes.find(t => t.id === typeIdNum);
|
||||
if (!type) return;
|
||||
|
||||
const newJob: MaintenanceJob = {
|
||||
typeId: typeIdNum,
|
||||
job: type.name,
|
||||
cost: costNum,
|
||||
notes: ''
|
||||
};
|
||||
|
||||
setMaintenanceJobs([...maintenanceJobs, newJob]);
|
||||
setCurrentTypeId("");
|
||||
setCurrentCost("");
|
||||
};
|
||||
|
||||
// Remove maintenance job
|
||||
const handleRemoveMaintenanceJob = (typeId: number) => {
|
||||
setMaintenanceJobs(maintenanceJobs.filter(job => job.typeId !== typeId));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Text weight="medium" size="lg">
|
||||
{visit ? "تعديل زيارة الصيانة" : "إضافة زيارة صيانة جديدة"}
|
||||
</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Form method="post" className="space-y-6">
|
||||
{visit && (
|
||||
<input type="hidden" name="id" value={visit.id} />
|
||||
)}
|
||||
|
||||
{/* Plate Number Autocomplete - Only show for new visits */}
|
||||
{!visit && (
|
||||
<div>
|
||||
<AutocompleteInput
|
||||
label="رقم اللوحة"
|
||||
placeholder="ابدأ بكتابة رقم اللوحة..."
|
||||
value={plateNumberInput}
|
||||
onChange={setPlateNumberInput}
|
||||
onSelect={handlePlateNumberSelect}
|
||||
options={plateNumberOptions}
|
||||
required
|
||||
/>
|
||||
<Text size="sm" color="secondary" className="mt-1">
|
||||
ابدأ بكتابة رقم اللوحة لاختيار المركبة والعميل تلقائياً
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Customer Selection */}
|
||||
<div>
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
العميل
|
||||
<span className="text-red-500 mr-1">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="customerId"
|
||||
value={selectedCustomerId}
|
||||
onChange={(e) => setSelectedCustomerId(e.target.value)}
|
||||
required
|
||||
disabled={false}
|
||||
className={`w-full px-3 py-2 border rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 appearance-none bg-white ${actionData?.errors?.customerId
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<option value="">اختر العميل</option>
|
||||
{customers.map((customer) => (
|
||||
<option key={customer.id} value={customer.id.toString()}>
|
||||
{customer.name}{customer.phone ? ` - ${customer.phone}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none mt-7">
|
||||
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{actionData?.errors?.customerId && (
|
||||
<Text size="sm" color="error" className="mt-1">
|
||||
{actionData.errors.customerId}
|
||||
</Text>
|
||||
)}
|
||||
{!visit && plateNumberInput && selectedCustomerId && (
|
||||
<Text size="sm" color="success" className="mt-1">
|
||||
تم اختيار العميل تلقائياً من رقم اللوحة
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vehicle Selection */}
|
||||
<div>
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
المركبة
|
||||
<span className="text-red-500 mr-1">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="vehicleId"
|
||||
value={selectedVehicleId}
|
||||
onChange={(e) => setSelectedVehicleId(e.target.value)}
|
||||
required
|
||||
disabled={false}
|
||||
className={`w-full px-3 py-2 border rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 appearance-none bg-white ${actionData?.errors?.vehicleId
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<option value="">اختر المركبة</option>
|
||||
{filteredVehicles.map((vehicle) => (
|
||||
<option key={vehicle.id} value={vehicle.id.toString()}>
|
||||
{vehicle.plateNumber} - {vehicle.manufacturer} {vehicle.model} ({vehicle.year})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none mt-7">
|
||||
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{actionData?.errors?.vehicleId && (
|
||||
<Text size="sm" color="error" className="mt-1">
|
||||
{actionData.errors.vehicleId}
|
||||
</Text>
|
||||
)}
|
||||
{!selectedCustomerId && !plateNumberInput && (
|
||||
<Text size="sm" color="secondary" className="mt-1">
|
||||
يرجى اختيار العميل أولاً أو البحث برقم اللوحة
|
||||
</Text>
|
||||
)}
|
||||
{!visit && plateNumberInput && selectedVehicleId && (
|
||||
<Text size="sm" color="success" className="mt-1">
|
||||
تم اختيار المركبة تلقائياً من رقم اللوحة
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maintenance Types Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
أنواع الصيانة
|
||||
<span className="text-red-500 mr-1">*</span>
|
||||
</label>
|
||||
|
||||
{/* Add Maintenance Type Form */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="flex-1">
|
||||
<select
|
||||
value={currentTypeId}
|
||||
onChange={(e) => setCurrentTypeId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">اختر نوع الصيانة</option>
|
||||
{maintenanceTypes
|
||||
.filter(type => !maintenanceJobs.some(job => job.typeId === type.id))
|
||||
.map((type) => (
|
||||
<option key={type.id} value={type.id.toString()}>
|
||||
{type.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-32">
|
||||
<input
|
||||
type="number"
|
||||
value={currentCost}
|
||||
onChange={(e) => setCurrentCost(e.target.value)}
|
||||
placeholder="التكلفة"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddMaintenanceJob}
|
||||
disabled={!currentTypeId || !currentCost}
|
||||
size="sm"
|
||||
>
|
||||
إضافة
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List of Added Maintenance Types */}
|
||||
{maintenanceJobs.length > 0 && (
|
||||
<div className="space-y-2 mb-3">
|
||||
{maintenanceJobs.map((job) => (
|
||||
<div
|
||||
key={job.typeId}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-md border border-gray-200"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<span className="font-medium">{job.job}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-gray-700">{job.cost.toFixed(2)} {settings.currency}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveMaintenanceJob(job.typeId)}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{maintenanceJobs.length === 0 && (
|
||||
<Text size="sm" color="secondary" className="mb-3">
|
||||
لم يتم إضافة أي نوع صيانة بعد
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Hidden input to pass maintenance jobs data */}
|
||||
<input
|
||||
type="hidden"
|
||||
name="maintenanceJobsData"
|
||||
value={JSON.stringify(maintenanceJobs)}
|
||||
/>
|
||||
|
||||
{actionData?.errors?.maintenanceJobs && (
|
||||
<Text size="sm" color="error" className="mt-1">
|
||||
{actionData.errors.maintenanceJobs}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-6">
|
||||
{/* Payment Status */}
|
||||
<div>
|
||||
<Select
|
||||
name="paymentStatus"
|
||||
label="حالة الدفع"
|
||||
defaultValue={visit?.paymentStatus || "pending"}
|
||||
error={actionData?.errors?.paymentStatus}
|
||||
required
|
||||
options={Object.entries(PAYMENT_STATUS_NAMES).map(([value, label]) => ({
|
||||
value: value,
|
||||
label: label
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
وصف الصيانة
|
||||
<span className="text-red-500 mr-1">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
defaultValue={visit?.description || ""}
|
||||
placeholder="اكتب وصف تفصيلي للأعمال المنجزة..."
|
||||
required
|
||||
/>
|
||||
{actionData?.errors?.description && (
|
||||
<Text size="sm" color="error" className="mt-1">
|
||||
{actionData.errors.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Cost */}
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
name="cost"
|
||||
label={`التكلفة الإجمالية (${settings.currency})`}
|
||||
value={totalCost.toFixed(2)}
|
||||
error={actionData?.errors?.cost}
|
||||
step="0.01"
|
||||
min="0"
|
||||
readOnly
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
<Text size="sm" color="secondary" className="mt-1">
|
||||
يتم حساب التكلفة تلقائياً من أنواع الصيانة المضافة
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Kilometers */}
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
name="kilometers"
|
||||
label="عدد الكيلومترات"
|
||||
defaultValue={visit?.kilometers?.toString() || ""}
|
||||
error={actionData?.errors?.kilometers}
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Next Visit Delay */}
|
||||
<div>
|
||||
<Select
|
||||
name="nextVisitDelay"
|
||||
label="الزيارة التالية بعد"
|
||||
defaultValue={visit?.nextVisitDelay?.toString() || "3"}
|
||||
error={actionData?.errors?.nextVisitDelay}
|
||||
required
|
||||
options={VISIT_DELAY_OPTIONS.map((option) => ({
|
||||
value: option.value.toString(),
|
||||
label: option.label
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visit Date */}
|
||||
<div>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
name="visitDate"
|
||||
label="تاريخ ووقت الزيارة"
|
||||
defaultValue={formatDateForInput(visit?.visitDate || new Date())}
|
||||
error={actionData?.errors?.visitDate}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{/* Debug Info */}
|
||||
{!visit && (
|
||||
<div className="bg-gray-50 p-3 rounded text-xs">
|
||||
<strong>Debug Info:</strong><br />
|
||||
Customer ID: {selectedCustomerId || "Not selected"}<br />
|
||||
Vehicle ID: {selectedVehicleId || "Not selected"}<br />
|
||||
Plate Number: {plateNumberInput || "Not entered"}<br />
|
||||
Selected Maintenance Types: {maintenanceJobs.length} types<br />
|
||||
Types: {maintenanceJobs.map(j => j.job).join(', ') || "None selected"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
إلغاء
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
name="intent"
|
||||
value={visit ? "update" : "create"}
|
||||
disabled={isSubmitting}
|
||||
onClick={(e) => {
|
||||
// Client-side validation before submission
|
||||
if (!visit) {
|
||||
const form = e.currentTarget.form;
|
||||
if (!form) return;
|
||||
|
||||
const formData = new FormData(form);
|
||||
const customerId = formData.get("customerId") as string;
|
||||
const vehicleId = formData.get("vehicleId") as string;
|
||||
const description = formData.get("description") as string;
|
||||
const cost = formData.get("cost") as string;
|
||||
const kilometers = formData.get("kilometers") as string;
|
||||
|
||||
const hasValidCustomer = customerId && customerId !== "";
|
||||
const hasValidVehicle = vehicleId && vehicleId !== "";
|
||||
const hasValidJobs = maintenanceJobs.length > 0;
|
||||
const hasValidDescription = description && description.trim() !== "";
|
||||
const hasValidCost = cost && cost.trim() !== "" && parseFloat(cost) > 0;
|
||||
const hasValidKilometers = kilometers && kilometers.trim() !== "" && parseInt(kilometers) >= 0;
|
||||
|
||||
const missingFields = [];
|
||||
if (!hasValidCustomer) missingFields.push("العميل");
|
||||
if (!hasValidVehicle) missingFields.push("المركبة");
|
||||
if (!hasValidJobs) missingFields.push("نوع صيانة واحد على الأقل");
|
||||
if (!hasValidDescription) missingFields.push("وصف الصيانة");
|
||||
if (!hasValidCost) missingFields.push("التكلفة");
|
||||
if (!hasValidKilometers) missingFields.push("عدد الكيلومترات");
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
e.preventDefault();
|
||||
alert(`يرجى ملء الحقول المطلوبة التالية:\n- ${missingFields.join('\n- ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSubmitting
|
||||
? visit
|
||||
? "جاري التحديث..."
|
||||
: "جاري الحفظ..."
|
||||
: visit
|
||||
? "تحديث الزيارة"
|
||||
: "حفظ الزيارة"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { Button, Input, Select, Text, Card, CardHeader, CardBody, MultiSelect } from "~/components/ui";
|
||||
import { AutocompleteInput } from "~/components/ui/AutocompleteInput";
|
||||
import type { MaintenanceVisitWithRelations, Customer, Vehicle, MaintenanceType, MaintenanceJob } from "~/types/database";
|
||||
import { PAYMENT_STATUS_NAMES, VISIT_DELAY_OPTIONS } from "~/lib/constants";
|
||||
|
||||
interface MaintenanceVisitFormProps {
|
||||
visit?: MaintenanceVisitWithRelations;
|
||||
customers: Customer[];
|
||||
vehicles: Vehicle[];
|
||||
maintenanceTypes: { id: number; name: string; }[];
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function MaintenanceVisitForm({
|
||||
visit,
|
||||
customers,
|
||||
vehicles,
|
||||
maintenanceTypes,
|
||||
onCancel
|
||||
}: MaintenanceVisitFormProps) {
|
||||
const actionData = useActionData<any>();
|
||||
const navigation = useNavigation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
// Form state
|
||||
const [plateNumberInput, setPlateNumberInput] = useState<string>(
|
||||
visit?.vehicle?.plateNumber || ""
|
||||
);
|
||||
const [selectedCustomerId, setSelectedCustomerId] = useState<string>(
|
||||
visit?.customerId?.toString() || ""
|
||||
);
|
||||
const [selectedVehicleId, setSelectedVehicleId] = useState<string>(
|
||||
visit?.vehicleId?.toString() || ""
|
||||
);
|
||||
const [filteredVehicles, setFilteredVehicles] = useState<Vehicle[]>(vehicles);
|
||||
|
||||
// Selected maintenance types state
|
||||
const [selectedMaintenanceTypes, setSelectedMaintenanceTypes] = useState<number[]>(() => {
|
||||
if (visit?.maintenanceJobs) {
|
||||
try {
|
||||
const jobs = JSON.parse(visit.maintenanceJobs);
|
||||
return jobs.map((job: MaintenanceJob) => job.typeId).filter((id: number) => id > 0);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// Create autocomplete options for plate numbers
|
||||
const plateNumberOptions = vehicles.map(vehicle => {
|
||||
const customer = customers.find(c => c.id === vehicle.ownerId);
|
||||
return {
|
||||
value: vehicle.plateNumber,
|
||||
label: `${vehicle.plateNumber} - ${vehicle.manufacturer} ${vehicle.model} (${customer?.name || 'غير محدد'})`,
|
||||
data: {
|
||||
vehicle,
|
||||
customer
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Handle plate number selection
|
||||
const handlePlateNumberSelect = (option: any) => {
|
||||
const { vehicle, customer } = option.data;
|
||||
setPlateNumberInput(vehicle.plateNumber);
|
||||
setSelectedCustomerId(customer?.id?.toString() || "");
|
||||
setSelectedVehicleId(vehicle.id.toString());
|
||||
};
|
||||
|
||||
// Reset form state when visit prop changes (switching between create/edit modes)
|
||||
useEffect(() => {
|
||||
if (visit) {
|
||||
// Editing mode - populate with visit data
|
||||
setPlateNumberInput(visit.vehicle?.plateNumber || "");
|
||||
setSelectedCustomerId(visit.customerId?.toString() || "");
|
||||
setSelectedVehicleId(visit.vehicleId?.toString() || "");
|
||||
|
||||
// Parse maintenance jobs from JSON
|
||||
try {
|
||||
const jobs = JSON.parse(visit.maintenanceJobs);
|
||||
const typeIds = jobs.map((job: MaintenanceJob) => job.typeId).filter((id: number) => id > 0);
|
||||
setSelectedMaintenanceTypes(typeIds);
|
||||
} catch {
|
||||
setSelectedMaintenanceTypes([]);
|
||||
}
|
||||
} else {
|
||||
// Create mode - reset to empty state
|
||||
setPlateNumberInput("");
|
||||
setSelectedCustomerId("");
|
||||
setSelectedVehicleId("");
|
||||
setSelectedMaintenanceTypes([]);
|
||||
}
|
||||
}, [visit]);
|
||||
|
||||
// Filter vehicles based on selected customer
|
||||
useEffect(() => {
|
||||
if (selectedCustomerId) {
|
||||
const customerId = parseInt(selectedCustomerId);
|
||||
const customerVehicles = vehicles.filter(v => v.ownerId === customerId);
|
||||
setFilteredVehicles(customerVehicles);
|
||||
|
||||
// Reset vehicle selection if current vehicle doesn't belong to selected customer
|
||||
if (selectedVehicleId) {
|
||||
const vehicleId = parseInt(selectedVehicleId);
|
||||
const vehicleBelongsToCustomer = customerVehicles.some(v => v.id === vehicleId);
|
||||
if (!vehicleBelongsToCustomer) {
|
||||
setSelectedVehicleId("");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setFilteredVehicles(vehicles);
|
||||
}
|
||||
}, [selectedCustomerId, vehicles, selectedVehicleId]);
|
||||
|
||||
// Format date for input
|
||||
const formatDateForInput = (date: Date | string | null) => {
|
||||
if (!date) return "";
|
||||
const d = new Date(date);
|
||||
return d.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM format
|
||||
};
|
||||
|
||||
// Convert selected maintenance types to jobs format for submission
|
||||
const getMaintenanceJobsForSubmission = () => {
|
||||
return selectedMaintenanceTypes.map(typeId => {
|
||||
const type = maintenanceTypes.find(t => t.id === typeId);
|
||||
return {
|
||||
typeId,
|
||||
job: type?.name || '',
|
||||
cost: 0,
|
||||
notes: ''
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Text weight="medium" size="lg">
|
||||
{visit ? "تعديل زيارة الصيانة" : "إضافة زيارة صيانة جديدة"}
|
||||
</Text>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Form method="post" className="space-y-6">
|
||||
{visit && (
|
||||
<input type="hidden" name="id" value={visit.id} />
|
||||
)}
|
||||
|
||||
{/* Plate Number Autocomplete - Only show for new visits */}
|
||||
{!visit && (
|
||||
<div>
|
||||
<AutocompleteInput
|
||||
label="رقم اللوحة"
|
||||
placeholder="ابدأ بكتابة رقم اللوحة..."
|
||||
value={plateNumberInput}
|
||||
onChange={setPlateNumberInput}
|
||||
onSelect={handlePlateNumberSelect}
|
||||
options={plateNumberOptions}
|
||||
required
|
||||
/>
|
||||
<Text size="sm" color="secondary" className="mt-1">
|
||||
ابدأ بكتابة رقم اللوحة لاختيار المركبة والعميل تلقائياً
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Customer Selection */}
|
||||
<div>
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
العميل
|
||||
<span className="text-red-500 mr-1">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="customerId"
|
||||
value={selectedCustomerId}
|
||||
onChange={(e) => setSelectedCustomerId(e.target.value)}
|
||||
required
|
||||
disabled={false}
|
||||
className={`w-full px-3 py-2 border rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 appearance-none bg-white ${actionData?.errors?.customerId
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<option value="">اختر العميل</option>
|
||||
{customers.map((customer) => (
|
||||
<option key={customer.id} value={customer.id.toString()}>
|
||||
{customer.name}{customer.phone ? ` - ${customer.phone}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none mt-7">
|
||||
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{actionData?.errors?.customerId && (
|
||||
<Text size="sm" color="error" className="mt-1">
|
||||
{actionData.errors.customerId}
|
||||
</Text>
|
||||
)}
|
||||
{!visit && plateNumberInput && selectedCustomerId && (
|
||||
<Text size="sm" color="success" className="mt-1">
|
||||
تم اختيار العميل تلقائياً من رقم اللوحة
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vehicle Selection */}
|
||||
<div>
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
المركبة
|
||||
<span className="text-red-500 mr-1">*</span>
|
||||
</label>
|
||||
<select
|
||||
name="vehicleId"
|
||||
value={selectedVehicleId}
|
||||
onChange={(e) => setSelectedVehicleId(e.target.value)}
|
||||
required
|
||||
disabled={false}
|
||||
className={`w-full px-3 py-2 border rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 appearance-none bg-white ${actionData?.errors?.vehicleId
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<option value="">اختر المركبة</option>
|
||||
{filteredVehicles.map((vehicle) => (
|
||||
<option key={vehicle.id} value={vehicle.id.toString()}>
|
||||
{vehicle.plateNumber} - {vehicle.manufacturer} {vehicle.model} ({vehicle.year})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none mt-7">
|
||||
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{actionData?.errors?.vehicleId && (
|
||||
<Text size="sm" color="error" className="mt-1">
|
||||
{actionData.errors.vehicleId}
|
||||
</Text>
|
||||
)}
|
||||
{!selectedCustomerId && !plateNumberInput && (
|
||||
<Text size="sm" color="secondary" className="mt-1">
|
||||
يرجى اختيار العميل أولاً أو البحث برقم اللوحة
|
||||
</Text>
|
||||
)}
|
||||
{!visit && plateNumberInput && selectedVehicleId && (
|
||||
<Text size="sm" color="success" className="mt-1">
|
||||
تم اختيار المركبة تلقائياً من رقم اللوحة
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maintenance Types Selection */}
|
||||
<div>
|
||||
<MultiSelect
|
||||
name="maintenanceJobs"
|
||||
label="أنواع الصيانة"
|
||||
options={maintenanceTypes.map(type => ({
|
||||
value: type.id,
|
||||
label: type.name
|
||||
}))}
|
||||
value={selectedMaintenanceTypes}
|
||||
onChange={setSelectedMaintenanceTypes}
|
||||
placeholder="اختر أنواع الصيانة المطلوبة..."
|
||||
error={actionData?.errors?.maintenanceJobs}
|
||||
required
|
||||
/>
|
||||
<Text size="sm" color="secondary" className="mt-1">
|
||||
يمكنك اختيار أكثر من نوع صيانة واحد
|
||||
</Text>
|
||||
|
||||
{/* Hidden input to pass maintenance jobs data in the expected format */}
|
||||
<input
|
||||
type="hidden"
|
||||
name="maintenanceJobsData"
|
||||
value={JSON.stringify(getMaintenanceJobsForSubmission())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-6">
|
||||
{/* Payment Status */}
|
||||
<div>
|
||||
<Select
|
||||
name="paymentStatus"
|
||||
label="حالة الدفع"
|
||||
defaultValue={visit?.paymentStatus || "pending"}
|
||||
error={actionData?.errors?.paymentStatus}
|
||||
required
|
||||
options={Object.entries(PAYMENT_STATUS_NAMES).map(([value, label]) => ({
|
||||
value: value,
|
||||
label: label
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
وصف الصيانة
|
||||
<span className="text-red-500 mr-1">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
defaultValue={visit?.description || ""}
|
||||
placeholder="اكتب وصف تفصيلي للأعمال المنجزة..."
|
||||
required
|
||||
/>
|
||||
{actionData?.errors?.description && (
|
||||
<Text size="sm" color="error" className="mt-1">
|
||||
{actionData.errors.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Cost */}
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
name="cost"
|
||||
label="التكلفة (ريال)"
|
||||
defaultValue={visit?.cost?.toString() || ""}
|
||||
error={actionData?.errors?.cost}
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="999999.99"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Kilometers */}
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
name="kilometers"
|
||||
label="عدد الكيلومترات"
|
||||
defaultValue={visit?.kilometers?.toString() || ""}
|
||||
error={actionData?.errors?.kilometers}
|
||||
min="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Next Visit Delay */}
|
||||
<div>
|
||||
<Select
|
||||
name="nextVisitDelay"
|
||||
label="الزيارة التالية بعد"
|
||||
defaultValue={visit?.nextVisitDelay?.toString() || "3"}
|
||||
error={actionData?.errors?.nextVisitDelay}
|
||||
required
|
||||
options={VISIT_DELAY_OPTIONS.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visit Date */}
|
||||
<div>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
name="visitDate"
|
||||
label="تاريخ ووقت الزيارة"
|
||||
defaultValue={formatDateForInput(visit?.visitDate || new Date())}
|
||||
error={actionData?.errors?.visitDate}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
{/* Debug Info */}
|
||||
{!visit && (
|
||||
<div className="bg-gray-50 p-3 rounded text-xs">
|
||||
<strong>Debug Info:</strong><br />
|
||||
Customer ID: {selectedCustomerId || "Not selected"}<br />
|
||||
Vehicle ID: {selectedVehicleId || "Not selected"}<br />
|
||||
Plate Number: {plateNumberInput || "Not entered"}<br />
|
||||
Selected Maintenance Types: {selectedMaintenanceTypes.length} types<br />
|
||||
Types: {selectedMaintenanceTypes.join(', ') || "None selected"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
إلغاء
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
name="intent"
|
||||
value={visit ? "update" : "create"}
|
||||
disabled={isSubmitting}
|
||||
onClick={(e) => {
|
||||
// Client-side validation before submission
|
||||
if (!visit) {
|
||||
const form = e.currentTarget.form;
|
||||
if (!form) return;
|
||||
|
||||
const formData = new FormData(form);
|
||||
const customerId = formData.get("customerId") as string;
|
||||
const vehicleId = formData.get("vehicleId") as string;
|
||||
const description = formData.get("description") as string;
|
||||
const cost = formData.get("cost") as string;
|
||||
const kilometers = formData.get("kilometers") as string;
|
||||
|
||||
const hasValidCustomer = customerId && customerId !== "";
|
||||
const hasValidVehicle = vehicleId && vehicleId !== "";
|
||||
const hasValidJobs = selectedMaintenanceTypes.length > 0;
|
||||
const hasValidDescription = description && description.trim() !== "";
|
||||
const hasValidCost = cost && cost.trim() !== "" && parseFloat(cost) > 0;
|
||||
const hasValidKilometers = kilometers && kilometers.trim() !== "" && parseInt(kilometers) >= 0;
|
||||
|
||||
const missingFields = [];
|
||||
if (!hasValidCustomer) missingFields.push("العميل");
|
||||
if (!hasValidVehicle) missingFields.push("المركبة");
|
||||
if (!hasValidJobs) missingFields.push("نوع صيانة واحد على الأقل");
|
||||
if (!hasValidDescription) missingFields.push("وصف الصيانة");
|
||||
if (!hasValidCost) missingFields.push("التكلفة");
|
||||
if (!hasValidKilometers) missingFields.push("عدد الكيلومترات");
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
e.preventDefault();
|
||||
alert(`يرجى ملء الحقول المطلوبة التالية:\n- ${missingFields.join('\n- ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSubmitting
|
||||
? visit
|
||||
? "جاري التحديث..."
|
||||
: "جاري الحفظ..."
|
||||
: visit
|
||||
? "تحديث الزيارة"
|
||||
: "حفظ الزيارة"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
253
app/components/maintenance-visits/MaintenanceVisitList.tsx
Normal file
253
app/components/maintenance-visits/MaintenanceVisitList.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useState } from "react";
|
||||
import { Link, Form } from "@remix-run/react";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Text } from "~/components/ui/Text";
|
||||
import { DataTable } from "~/components/ui/DataTable";
|
||||
import type { MaintenanceVisitWithRelations } from "~/types/database";
|
||||
import { PAYMENT_STATUS_NAMES } from "~/lib/constants";
|
||||
import { useSettings } from "~/contexts/SettingsContext";
|
||||
|
||||
interface MaintenanceVisitListProps {
|
||||
visits: MaintenanceVisitWithRelations[];
|
||||
onEdit?: (visit: MaintenanceVisitWithRelations) => void;
|
||||
onView?: (visit: MaintenanceVisitWithRelations) => void;
|
||||
}
|
||||
|
||||
export function MaintenanceVisitList({
|
||||
visits,
|
||||
onEdit,
|
||||
onView
|
||||
}: MaintenanceVisitListProps) {
|
||||
const { formatDate, formatCurrency, formatNumber, formatDateTime } = useSettings();
|
||||
const [deleteVisitId, setDeleteVisitId] = useState<number | null>(null);
|
||||
|
||||
const handleDelete = (visitId: number) => {
|
||||
setDeleteVisitId(visitId);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteVisitId) {
|
||||
// Submit delete form
|
||||
const form = document.createElement('form');
|
||||
form.method = 'post';
|
||||
form.style.display = 'none';
|
||||
|
||||
const intentInput = document.createElement('input');
|
||||
intentInput.type = 'hidden';
|
||||
intentInput.name = 'intent';
|
||||
intentInput.value = 'delete';
|
||||
|
||||
const idInput = document.createElement('input');
|
||||
idInput.type = 'hidden';
|
||||
idInput.name = 'id';
|
||||
idInput.value = deleteVisitId.toString();
|
||||
|
||||
form.appendChild(intentInput);
|
||||
form.appendChild(idInput);
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
};
|
||||
|
||||
const getPaymentStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return 'text-green-600 bg-green-50';
|
||||
case 'pending':
|
||||
return 'text-yellow-600 bg-yellow-50';
|
||||
case 'partial':
|
||||
return 'text-blue-600 bg-blue-50';
|
||||
case 'cancelled':
|
||||
return 'text-red-600 bg-red-50';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'visitDate',
|
||||
header: 'تاريخ الزيارة',
|
||||
render: (visit: MaintenanceVisitWithRelations) => (
|
||||
<div>
|
||||
<Text weight="medium">
|
||||
{formatDate(visit.visitDate)}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{formatDateTime(visit.visitDate).split(' ')[1]}
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'vehicle',
|
||||
header: 'المركبة',
|
||||
render: (visit: MaintenanceVisitWithRelations) => (
|
||||
<div>
|
||||
<Text weight="medium">{visit.vehicle.plateNumber}</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{visit.vehicle.manufacturer} {visit.vehicle.model} ({visit.vehicle.year})
|
||||
</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'customer',
|
||||
header: 'العميل',
|
||||
render: (visit: MaintenanceVisitWithRelations) => (
|
||||
<div>
|
||||
<Text weight="medium">{visit.customer.name}</Text>
|
||||
{visit.customer.phone && (
|
||||
<Text size="sm" color="secondary">{visit.customer.phone}</Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'maintenanceJobs',
|
||||
header: 'أعمال الصيانة',
|
||||
render: (visit: MaintenanceVisitWithRelations) => {
|
||||
try {
|
||||
const jobs = JSON.parse(visit.maintenanceJobs);
|
||||
return (
|
||||
<div>
|
||||
<Text weight="medium">
|
||||
{jobs.length > 1 ? `${jobs.length} أعمال صيانة` : jobs[0]?.job || 'غير محدد'}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary" className="line-clamp-2">
|
||||
{visit.description}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return (
|
||||
<div>
|
||||
<Text weight="medium">غير محدد</Text>
|
||||
<Text size="sm" color="secondary" className="line-clamp-2">
|
||||
{visit.description}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'cost',
|
||||
header: 'التكلفة',
|
||||
render: (visit: MaintenanceVisitWithRelations) => (
|
||||
<Text weight="medium" className="font-mono">
|
||||
{formatCurrency(visit.cost)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'paymentStatus',
|
||||
header: 'حالة الدفع',
|
||||
render: (visit: MaintenanceVisitWithRelations) => (
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${getPaymentStatusColor(visit.paymentStatus)}`}>
|
||||
{PAYMENT_STATUS_NAMES[visit.paymentStatus as keyof typeof PAYMENT_STATUS_NAMES]}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'kilometers',
|
||||
header: 'الكيلومترات',
|
||||
render: (visit: MaintenanceVisitWithRelations) => (
|
||||
<Text className="font-mono">
|
||||
{formatNumber(visit.kilometers)} كم
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'الإجراءات',
|
||||
render: (visit: MaintenanceVisitWithRelations) => (
|
||||
<div className="flex gap-2">
|
||||
{onView ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onView(visit)}
|
||||
>
|
||||
عرض
|
||||
</Button>
|
||||
) : (
|
||||
<Link to={`/maintenance-visits/${visit.id}`}>
|
||||
<Button size="sm" variant="outline">
|
||||
عرض
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{onEdit && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onEdit(visit)}
|
||||
>
|
||||
تعديل
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-red-600 border-red-300 hover:bg-red-50"
|
||||
onClick={() => handleDelete(visit.id)}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-lg shadow-sm border">
|
||||
{visits.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 text-lg mb-2">🔧</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
لا توجد زيارات صيانة
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
لم يتم العثور على أي زيارات صيانة. قم بإضافة زيارة جديدة للبدء.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={visits}
|
||||
columns={columns}
|
||||
emptyMessage="لم يتم العثور على أي زيارات صيانة"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteVisitId !== null && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">تأكيد الحذف</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
هل أنت متأكد من حذف زيارة الصيانة هذه؟ سيتم حذف جميع البيانات المرتبطة بها نهائياً.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteVisitId(null)}
|
||||
>
|
||||
إلغاء
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-red-600 border-red-300 hover:bg-red-50"
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
app/components/maintenance-visits/index.ts
Normal file
2
app/components/maintenance-visits/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MaintenanceVisitForm } from './MaintenanceVisitForm';
|
||||
export { MaintenanceVisitList } from './MaintenanceVisitList';
|
||||
212
app/components/tables/EnhancedCustomerTable.tsx
Normal file
212
app/components/tables/EnhancedCustomerTable.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { DataTable } from '~/components/ui/DataTable';
|
||||
import { Button } from '~/components/ui/Button';
|
||||
import { Text } from '~/components/ui/Text';
|
||||
import { processTableData, type TableState } from '~/lib/table-utils';
|
||||
import { useSettings } from '~/contexts/SettingsContext';
|
||||
import type { Customer } from '~/types/database';
|
||||
|
||||
interface EnhancedCustomerTableProps {
|
||||
customers: Customer[];
|
||||
loading?: boolean;
|
||||
onEdit?: (customer: Customer) => void;
|
||||
onDelete?: (customer: Customer) => void;
|
||||
onView?: (customer: Customer) => void;
|
||||
}
|
||||
|
||||
export function EnhancedCustomerTable({
|
||||
customers,
|
||||
loading = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onView,
|
||||
}: EnhancedCustomerTableProps) {
|
||||
const { formatDate } = useSettings();
|
||||
const [tableState, setTableState] = useState<TableState>({
|
||||
search: '',
|
||||
filters: {},
|
||||
sort: { key: 'name', direction: 'asc' },
|
||||
pagination: { page: 1, pageSize: 10 },
|
||||
});
|
||||
|
||||
// Define searchable fields
|
||||
const searchableFields = ['name', 'phone', 'email', 'address'] as const;
|
||||
|
||||
// Process table data
|
||||
const processedData = useMemo(() => {
|
||||
return processTableData(customers, tableState, searchableFields);
|
||||
}, [customers, tableState]);
|
||||
|
||||
// Handle sorting
|
||||
const handleSort = (key: string, direction: 'asc' | 'desc') => {
|
||||
setTableState(prev => ({
|
||||
...prev,
|
||||
sort: { key, direction },
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle page change
|
||||
const handlePageChange = (page: number) => {
|
||||
setTableState(prev => ({
|
||||
...prev,
|
||||
pagination: { ...prev.pagination, page },
|
||||
}));
|
||||
};
|
||||
|
||||
// Define table columns
|
||||
const columns = [
|
||||
{
|
||||
key: 'name' as keyof Customer,
|
||||
header: 'اسم العميل',
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: (customer: Customer) => (
|
||||
<div>
|
||||
<Text weight="medium">{customer.name}</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'phone' as keyof Customer,
|
||||
header: 'رقم الهاتف',
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: (customer: Customer) => (
|
||||
<Text dir="ltr" className="text-left">
|
||||
{customer.phone || '-'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'email' as keyof Customer,
|
||||
header: 'البريد الإلكتروني',
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
render: (customer: Customer) => (
|
||||
<Text dir="ltr" className="text-left">
|
||||
{customer.email || '-'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'address' as keyof Customer,
|
||||
header: 'العنوان',
|
||||
filterable: true,
|
||||
render: (customer: Customer) => (
|
||||
<Text className="max-w-xs truncate" title={customer.address || undefined}>
|
||||
{customer.address || '-'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdDate' as keyof Customer,
|
||||
header: 'تاريخ الإنشاء',
|
||||
sortable: true,
|
||||
render: (customer: Customer) => (
|
||||
<Text size="sm" color="secondary">
|
||||
{formatDate(customer.createdDate)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Table Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<Text size="lg" weight="semibold">
|
||||
قائمة العملاء
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
إدارة بيانات العملاء
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 space-x-reverse">
|
||||
<Text size="sm" color="secondary">
|
||||
المجموع: {processedData.originalCount}
|
||||
</Text>
|
||||
{processedData.filteredCount !== processedData.originalCount && (
|
||||
<Text size="sm" color="secondary">
|
||||
(مفلتر: {processedData.filteredCount})
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Data Table */}
|
||||
<DataTable
|
||||
data={processedData.data}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
emptyMessage="لا يوجد عملاء مسجلين"
|
||||
searchable
|
||||
searchPlaceholder="البحث في العملاء..."
|
||||
filterable
|
||||
onSort={handleSort}
|
||||
sortKey={tableState.sort?.key}
|
||||
sortDirection={tableState.sort?.direction}
|
||||
pagination={{
|
||||
enabled: true,
|
||||
currentPage: tableState.pagination.page,
|
||||
pageSize: tableState.pagination.pageSize,
|
||||
totalItems: processedData.filteredCount,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
actions={{
|
||||
label: 'الإجراءات',
|
||||
render: (customer: Customer) => (
|
||||
<div className="flex items-center space-x-2 space-x-reverse">
|
||||
{onView && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onView(customer)}
|
||||
icon={
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
عرض
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onEdit && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onEdit(customer)}
|
||||
icon={
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
تعديل
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onDelete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(customer)}
|
||||
icon={
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
app/components/ui/AutocompleteInput.tsx
Normal file
214
app/components/ui/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Input } from "./Input";
|
||||
|
||||
interface AutocompleteOption {
|
||||
value: string;
|
||||
label: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
interface AutocompleteInputProps {
|
||||
name?: string;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSelect?: (option: AutocompleteOption) => void;
|
||||
options: AutocompleteOption[];
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function AutocompleteInput({
|
||||
name,
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
onSelect,
|
||||
options,
|
||||
error,
|
||||
required,
|
||||
disabled,
|
||||
loading
|
||||
}: AutocompleteInputProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filter options based on input value
|
||||
const filteredOptions = options.filter(option =>
|
||||
option.label.toLowerCase().includes(value.toLowerCase()) ||
|
||||
option.value.toLowerCase().includes(value.toLowerCase())
|
||||
);
|
||||
|
||||
// Handle input change
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
onChange(newValue);
|
||||
setIsOpen(true);
|
||||
setHighlightedIndex(-1);
|
||||
};
|
||||
|
||||
// Handle option selection
|
||||
const handleOptionSelect = (option: AutocompleteOption) => {
|
||||
onChange(option.value);
|
||||
onSelect?.(option);
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
};
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === "ArrowDown" || e.key === "Enter") {
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(prev =>
|
||||
prev < filteredOptions.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex(prev =>
|
||||
prev > 0 ? prev - 1 : filteredOptions.length - 1
|
||||
);
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (highlightedIndex >= 0 && filteredOptions[highlightedIndex]) {
|
||||
handleOptionSelect(filteredOptions[highlightedIndex]);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Scroll highlighted option into view
|
||||
useEffect(() => {
|
||||
if (highlightedIndex >= 0 && listRef.current) {
|
||||
const highlightedElement = listRef.current.children[highlightedIndex] as HTMLElement;
|
||||
if (highlightedElement) {
|
||||
highlightedElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth"
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [highlightedIndex]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
name={name}
|
||||
label={label}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
error={error}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
endIcon={
|
||||
<div className="pointer-events-auto">
|
||||
{loading ? (
|
||||
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
) : isOpen ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && filteredOptions.length > 0 && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto">
|
||||
<ul ref={listRef} className="py-1">
|
||||
{filteredOptions.map((option, index) => (
|
||||
<li
|
||||
key={option.value}
|
||||
className={`px-3 py-2 cursor-pointer text-sm ${
|
||||
index === highlightedIndex
|
||||
? "bg-blue-50 text-blue-900"
|
||||
: "text-gray-900 hover:bg-gray-50"
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleOptionSelect(option);
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault(); // Prevent input from losing focus
|
||||
}}
|
||||
onMouseEnter={() => setHighlightedIndex(index)}
|
||||
>
|
||||
<div className="font-medium">{option.value}</div>
|
||||
{option.label !== option.value && (
|
||||
<div className="text-xs text-gray-500 mt-1">{option.label}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No results */}
|
||||
{isOpen && filteredOptions.length === 0 && value.length > 0 && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg">
|
||||
<div className="px-3 py-2 text-sm text-gray-500 text-center">
|
||||
لا توجد نتائج مطابقة
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
app/components/ui/Button.tsx
Normal file
92
app/components/ui/Button.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { ReactNode, ButtonHTMLAttributes } from 'react';
|
||||
import { getButtonClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'className'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
fullWidth?: boolean;
|
||||
loading?: boolean;
|
||||
icon?: ReactNode;
|
||||
iconPosition?: 'start' | 'end';
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
className = '',
|
||||
config = {},
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
loading = false,
|
||||
icon,
|
||||
iconPosition = 'start',
|
||||
disabled,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
const baseClasses = getButtonClasses(variant as any, size);
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500 disabled:bg-blue-300',
|
||||
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-900 focus:ring-gray-500 disabled:bg-gray-100',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500 disabled:bg-red-300',
|
||||
outline: 'border border-gray-300 bg-white hover:bg-gray-50 text-gray-700 focus:ring-blue-500 disabled:bg-gray-50',
|
||||
ghost: 'bg-transparent hover:bg-gray-100 text-gray-700 focus:ring-gray-500 disabled:text-gray-400',
|
||||
};
|
||||
|
||||
const fullWidthClass = fullWidth ? 'w-full' : '';
|
||||
const disabledClass = (disabled || loading) ? 'cursor-not-allowed opacity-50' : '';
|
||||
|
||||
const iconSpacing = layoutConfig.direction === 'rtl' ? 'space-x-reverse' : '';
|
||||
const iconOrderClass = iconPosition === 'end' ? 'flex-row-reverse' : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${variantClasses[variant]} ${fullWidthClass} ${disabledClass} ${iconSpacing} ${iconOrderClass} ${className}`}
|
||||
disabled={disabled || loading}
|
||||
dir={layoutConfig.direction}
|
||||
{...props}
|
||||
>
|
||||
{loading && (
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-5 w-5 text-current"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{icon && iconPosition === 'start' && !loading && (
|
||||
<span className={layoutConfig.direction === 'rtl' ? 'ml-2' : 'mr-2'}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span>{children}</span>
|
||||
|
||||
{icon && iconPosition === 'end' && !loading && (
|
||||
<span className={layoutConfig.direction === 'rtl' ? 'mr-2' : 'ml-2'}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
118
app/components/ui/Card.tsx
Normal file
118
app/components/ui/Card.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
shadow?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
border?: boolean;
|
||||
hover?: boolean;
|
||||
}
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className = '',
|
||||
config = {},
|
||||
padding = 'md',
|
||||
shadow = 'md',
|
||||
rounded = 'lg',
|
||||
border = true,
|
||||
hover = false
|
||||
}: CardProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4 sm:p-6',
|
||||
lg: 'p-6 sm:p-8',
|
||||
};
|
||||
|
||||
const shadowClasses = {
|
||||
none: '',
|
||||
sm: 'shadow-sm',
|
||||
md: 'shadow-md',
|
||||
lg: 'shadow-lg',
|
||||
xl: 'shadow-xl',
|
||||
};
|
||||
|
||||
const roundedClasses = {
|
||||
none: '',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl',
|
||||
};
|
||||
|
||||
const borderClass = border ? 'border border-gray-200' : '';
|
||||
const hoverClass = hover ? 'hover:shadow-lg transition-shadow duration-200' : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bg-white ${paddingClasses[padding]} ${shadowClasses[shadow]} ${roundedClasses[rounded]} ${borderClass} ${hoverClass} ${className}`}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardHeaderProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
}
|
||||
|
||||
export function CardHeader({ children, className = '', config = {} }: CardHeaderProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-b border-gray-200 pb-4 mb-4 ${className}`}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardBodyProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
}
|
||||
|
||||
export function CardBody({ children, className = '', config = {} }: CardBodyProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardFooterProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
}
|
||||
|
||||
export function CardFooter({ children, className = '', config = {} }: CardFooterProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-t border-gray-200 pt-4 mt-4 ${className}`}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
422
app/components/ui/DataTable.tsx
Normal file
422
app/components/ui/DataTable.tsx
Normal file
@@ -0,0 +1,422 @@
|
||||
import { ReactNode, memo, useState, useMemo } from 'react';
|
||||
import { Text } from './Text';
|
||||
import { Button } from './Button';
|
||||
import { Input } from './Input';
|
||||
import { Select } from './Select';
|
||||
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface Column<T> {
|
||||
key: keyof T | string;
|
||||
header: string;
|
||||
render?: (item: T) => ReactNode;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
filterType?: 'text' | 'select' | 'date' | 'number';
|
||||
filterOptions?: { value: string; label: string }[];
|
||||
className?: string;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
interface FilterState {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
onSort?: (key: string, direction: 'asc' | 'desc') => void;
|
||||
sortKey?: string;
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
filterable?: boolean;
|
||||
pagination?: {
|
||||
enabled: boolean;
|
||||
pageSize?: number;
|
||||
currentPage?: number;
|
||||
totalItems?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
};
|
||||
actions?: {
|
||||
label: string;
|
||||
render: (item: T) => ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export const DataTable = memo(function DataTable<T extends Record<string, any>>({
|
||||
data,
|
||||
columns,
|
||||
loading = false,
|
||||
emptyMessage = "لا توجد بيانات",
|
||||
className = '',
|
||||
config = {},
|
||||
onSort,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
searchable = false,
|
||||
searchPlaceholder = "البحث...",
|
||||
filterable = false,
|
||||
pagination,
|
||||
actions,
|
||||
}: DataTableProps<T>) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filters, setFilters] = useState<FilterState>({});
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (!onSort) return;
|
||||
|
||||
const newDirection = sortKey === key && sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
onSort(key, newDirection);
|
||||
};
|
||||
|
||||
const handleFilterChange = (columnKey: string, value: string) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[columnKey]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// Filter and search data
|
||||
const filteredData = useMemo(() => {
|
||||
let result = [...data];
|
||||
|
||||
// Apply search
|
||||
if (searchable && searchTerm) {
|
||||
result = result.filter(item => {
|
||||
return columns.some(column => {
|
||||
const value = item[column.key];
|
||||
if (value == null) return false;
|
||||
return String(value).toLowerCase().includes(searchTerm.toLowerCase());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Apply column filters
|
||||
if (filterable) {
|
||||
Object.entries(filters).forEach(([columnKey, filterValue]) => {
|
||||
if (filterValue) {
|
||||
result = result.filter(item => {
|
||||
const value = item[columnKey];
|
||||
if (value == null) return false;
|
||||
return String(value).toLowerCase().includes(filterValue.toLowerCase());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, searchTerm, filters, columns, searchable, filterable]);
|
||||
|
||||
// Paginate data
|
||||
const paginatedData = useMemo(() => {
|
||||
if (!pagination?.enabled) return filteredData;
|
||||
|
||||
const pageSize = pagination.pageSize || 10;
|
||||
const currentPage = pagination.currentPage || 1;
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
|
||||
return filteredData.slice(startIndex, endIndex);
|
||||
}, [filteredData, pagination]);
|
||||
|
||||
const totalPages = pagination?.enabled
|
||||
? Math.ceil(filteredData.length / (pagination.pageSize || 10))
|
||||
: 1;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border border-gray-200 ${className}`} dir={layoutConfig.direction}>
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<Text color="secondary">جاري التحميل...</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border border-gray-200 ${className}`} dir={layoutConfig.direction}>
|
||||
<div className="p-8 text-center">
|
||||
<svg
|
||||
className="mx-auto h-12 w-12 text-gray-400 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<Text size="lg" weight="medium" className="mb-2">
|
||||
لا توجد بيانات
|
||||
</Text>
|
||||
<Text color="secondary">{emptyMessage}</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border border-gray-200 overflow-hidden ${className}`} dir={layoutConfig.direction}>
|
||||
{/* Search and Filters */}
|
||||
{(searchable || filterable) && (
|
||||
<div className="p-4 border-b border-gray-200 bg-gray-50">
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Search */}
|
||||
{searchable && (
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
startIcon={
|
||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Column Filters */}
|
||||
{filterable && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{columns
|
||||
.filter(column => column.filterable)
|
||||
.map(column => (
|
||||
<div key={`filter-${column.key}`}>
|
||||
{column.filterType === 'select' && column.filterOptions ? (
|
||||
<Select
|
||||
placeholder={`تصفية ${column.header}`}
|
||||
value={filters[column.key as string] || ''}
|
||||
onChange={(e) => handleFilterChange(column.key as string, e.target.value)}
|
||||
options={[
|
||||
{ value: '', label: `جميع ${column.header}` },
|
||||
...column.filterOptions
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={`تصفية ${column.header}`}
|
||||
value={filters[column.key as string] || ''}
|
||||
onChange={(e) => handleFilterChange(column.key as string, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{columns.map((column) => (
|
||||
<th
|
||||
key={`header-${column.key}`}
|
||||
className={`px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider ${column.className || ''}`}
|
||||
style={column.width ? { width: column.width } : undefined}
|
||||
>
|
||||
{column.sortable && onSort ? (
|
||||
<button
|
||||
onClick={() => handleSort(column.key as string)}
|
||||
className="group inline-flex items-center space-x-1 space-x-reverse hover:text-gray-700"
|
||||
>
|
||||
<span>{column.header}</span>
|
||||
<span className="ml-2 flex-none rounded text-gray-400 group-hover:text-gray-500">
|
||||
{sortKey === column.key ? (
|
||||
sortDirection === 'asc' ? (
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)
|
||||
) : (
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M5 12a1 1 0 102 0V6.414l1.293 1.293a1 1 0 001.414-1.414l-3-3a1 1 0 00-1.414 0l-3 3a1 1 0 001.414 1.414L5 6.414V12zM15 8a1 1 0 10-2 0v5.586l-1.293-1.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L15 13.586V8z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
column.header
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
{actions && (
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{actions.label}
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{paginatedData.map((item, rowIndex) => {
|
||||
// Use item.id if available, otherwise fall back to rowIndex
|
||||
const rowKey = item.id ? `row-${item.id}` : `row-${rowIndex}`;
|
||||
return (
|
||||
<tr key={rowKey} className="hover:bg-gray-50">
|
||||
{columns.map((column) => (
|
||||
<td
|
||||
key={`${rowKey}-${column.key}`}
|
||||
className={`px-6 py-4 whitespace-nowrap text-sm text-gray-900 ${column.className || ''}`}
|
||||
>
|
||||
{column.render
|
||||
? column.render(item)
|
||||
: String(item[column.key] || '')
|
||||
}
|
||||
</td>
|
||||
))}
|
||||
{actions && (
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{actions.render(item)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination?.enabled && totalPages > 1 && (
|
||||
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||||
<Pagination
|
||||
currentPage={pagination.currentPage || 1}
|
||||
totalPages={totalPages}
|
||||
onPageChange={pagination.onPageChange || (() => {})}
|
||||
config={config}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Summary */}
|
||||
{(searchable || filterable || pagination?.enabled) && (
|
||||
<div className="px-4 py-2 border-t border-gray-200 bg-gray-50">
|
||||
<Text size="sm" color="secondary">
|
||||
عرض {paginatedData.length} من {filteredData.length}
|
||||
{filteredData.length !== data.length && ` (مفلتر من ${data.length})`}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}) as <T extends Record<string, any>>(props: DataTableProps<T>) => JSX.Element;
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
}
|
||||
|
||||
export const Pagination = memo(function Pagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
className = '',
|
||||
config = {},
|
||||
}: PaginationProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pages = [];
|
||||
const maxVisible = 5;
|
||||
|
||||
if (totalPages <= maxVisible) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
if (currentPage <= 3) {
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
pages.push('...');
|
||||
pages.push(totalPages);
|
||||
} else if (currentPage >= totalPages - 2) {
|
||||
pages.push(1);
|
||||
pages.push('...');
|
||||
for (let i = totalPages - 3; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
pages.push(1);
|
||||
pages.push('...');
|
||||
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
pages.push('...');
|
||||
pages.push(totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center justify-between ${className}`} dir={layoutConfig.direction}>
|
||||
<div className="flex items-center space-x-2 space-x-reverse">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
السابق
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center space-x-1 space-x-reverse">
|
||||
{getPageNumbers().map((page, index) => (
|
||||
<div key={index}>
|
||||
{page === '...' ? (
|
||||
<span className="px-3 py-2 text-gray-500">...</span>
|
||||
) : (
|
||||
<Button
|
||||
variant={currentPage === page ? "primary" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page as number)}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
التالي
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Text color="secondary" size="sm">
|
||||
صفحة {currentPage} من {totalPages}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
233
app/components/ui/Form.tsx
Normal file
233
app/components/ui/Form.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { ReactNode, FormHTMLAttributes } from 'react';
|
||||
import { Form as RemixForm } from '@remix-run/react';
|
||||
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
import { Button } from './Button';
|
||||
import { Text } from './Text';
|
||||
|
||||
interface FormProps extends Omit<FormHTMLAttributes<HTMLFormElement>, 'className'> {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
title?: string;
|
||||
description?: string;
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
success?: string;
|
||||
actions?: ReactNode;
|
||||
spacing?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function Form({
|
||||
children,
|
||||
className = '',
|
||||
config = {},
|
||||
title,
|
||||
description,
|
||||
loading = false,
|
||||
error,
|
||||
success,
|
||||
actions,
|
||||
spacing = 'md',
|
||||
...props
|
||||
}: FormProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
const spacingClasses = {
|
||||
sm: 'space-y-4',
|
||||
md: 'space-y-6',
|
||||
lg: 'space-y-8',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${className}`} dir={layoutConfig.direction}>
|
||||
{/* Form Header */}
|
||||
{(title || description) && (
|
||||
<div className="mb-6">
|
||||
{title && (
|
||||
<Text as="h2" size="xl" weight="semibold" className="mb-2">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text color="secondary">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<svg className="h-5 w-5 text-green-400 ml-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<Text color="success" size="sm">
|
||||
{success}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<svg className="h-5 w-5 text-red-400 ml-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<Text color="error" size="sm">
|
||||
{error}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Content */}
|
||||
<RemixForm className={spacingClasses[spacing]} {...props}>
|
||||
{children}
|
||||
|
||||
{/* Form Actions */}
|
||||
{actions && (
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</RemixForm>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{loading && (
|
||||
<div className="absolute inset-0 bg-white bg-opacity-75 flex items-center justify-center rounded-lg">
|
||||
<div className="flex items-center space-x-2 space-x-reverse">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<Text color="secondary">جاري المعالجة...</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Form Actions Component
|
||||
interface FormActionsProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
justify?: 'start' | 'center' | 'end' | 'between';
|
||||
spacing?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function FormActions({
|
||||
children,
|
||||
className = '',
|
||||
config = {},
|
||||
justify = 'end',
|
||||
spacing = 'md',
|
||||
}: FormActionsProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
const justifyClasses = {
|
||||
start: 'justify-start',
|
||||
center: 'justify-center',
|
||||
end: 'justify-end',
|
||||
between: 'justify-between',
|
||||
};
|
||||
|
||||
const spacingClasses = {
|
||||
sm: 'space-x-2 space-x-reverse',
|
||||
md: 'space-x-4 space-x-reverse',
|
||||
lg: 'space-x-6 space-x-reverse',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${justifyClasses[justify]} ${spacingClasses[spacing]} ${className}`}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Form Section Component
|
||||
interface FormSectionProps {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
}
|
||||
|
||||
export function FormSection({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
className = '',
|
||||
config = {},
|
||||
}: FormSectionProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
return (
|
||||
<div className={`${className}`} dir={layoutConfig.direction}>
|
||||
{(title || description) && (
|
||||
<div className="mb-4">
|
||||
{title && (
|
||||
<Text as="h3" size="lg" weight="medium" className="mb-1">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text color="secondary" size="sm">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Form Grid Component
|
||||
interface FormGridProps {
|
||||
children: ReactNode;
|
||||
columns?: 1 | 2 | 3 | 4;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
gap?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function FormGrid({
|
||||
children,
|
||||
columns = 2,
|
||||
className = '',
|
||||
config = {},
|
||||
gap = 'md',
|
||||
}: FormGridProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
const columnClasses = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
};
|
||||
|
||||
const gapClasses = {
|
||||
sm: 'gap-4',
|
||||
md: 'gap-6',
|
||||
lg: 'gap-8',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid ${columnClasses[columns]} ${gapClasses[gap]} ${className}`}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
app/components/ui/FormField.tsx
Normal file
54
app/components/ui/FormField.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface FormFieldProps {
|
||||
children: ReactNode;
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
htmlFor?: string;
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
children,
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
required = false,
|
||||
className = '',
|
||||
config = {},
|
||||
htmlFor,
|
||||
}: FormFieldProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
return (
|
||||
<div className={`${className}`} dir={layoutConfig.direction}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={`block text-sm font-medium mb-2 ${error ? 'text-red-700' : 'text-gray-700'} ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-red-500 mr-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
{error && (
|
||||
<p className={`mt-2 text-sm text-red-600 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{helperText && !error && (
|
||||
<p className={`mt-2 text-sm text-gray-500 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
app/components/ui/Input.tsx
Normal file
86
app/components/ui/Input.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { InputHTMLAttributes, forwardRef, useId } from 'react';
|
||||
import { getFormInputClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className'> {
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
fullWidth?: boolean;
|
||||
startIcon?: React.ReactNode;
|
||||
endIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(({
|
||||
className = '',
|
||||
config = {},
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
fullWidth = true,
|
||||
startIcon,
|
||||
endIcon,
|
||||
id,
|
||||
...props
|
||||
}, ref) => {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
const inputClasses = getFormInputClasses(!!error);
|
||||
|
||||
const fullWidthClass = fullWidth ? 'w-full' : '';
|
||||
const hasIconsClass = (startIcon || endIcon) ? 'relative' : '';
|
||||
|
||||
const generatedId = useId();
|
||||
const inputId = id || generatedId;
|
||||
|
||||
return (
|
||||
<div className={`${fullWidthClass} ${className}`} dir={layoutConfig.direction}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={`block text-sm font-medium mb-2 ${error ? 'text-red-700' : 'text-gray-700'} ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className={`relative ${hasIconsClass}`}>
|
||||
{startIcon && (
|
||||
<div className={`absolute inset-y-0 ${layoutConfig.direction === 'rtl' ? 'right-0 pr-3' : 'left-0 pl-3'} flex items-center pointer-events-none`}>
|
||||
<span className="text-gray-400 sm:text-sm">
|
||||
{startIcon}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`${inputClasses} ${startIcon ? (layoutConfig.direction === 'rtl' ? 'pr-10' : 'pl-10') : ''} ${endIcon ? (layoutConfig.direction === 'rtl' ? 'pl-10' : 'pr-10') : ''}`}
|
||||
dir={layoutConfig.direction}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{endIcon && (
|
||||
<div className={`absolute inset-y-0 ${layoutConfig.direction === 'rtl' ? 'left-0 pl-3' : 'right-0 pr-3'} flex items-center pointer-events-none`}>
|
||||
<span className="text-gray-400 sm:text-sm">
|
||||
{endIcon}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className={`mt-2 text-sm text-red-600 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{helperText && !error && (
|
||||
<p className={`mt-2 text-sm text-gray-500 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
220
app/components/ui/Modal.tsx
Normal file
220
app/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button } from './Button';
|
||||
import { Text } from './Text';
|
||||
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'md',
|
||||
className = '',
|
||||
config = {},
|
||||
showCloseButton = true,
|
||||
}: ModalProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen || !isMounted) return null;
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
};
|
||||
|
||||
const modalContent = (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto" dir={layoutConfig.direction}>
|
||||
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal panel */}
|
||||
<div
|
||||
className={`relative transform overflow-hidden rounded-lg bg-white text-right shadow-xl transition-all sm:my-8 sm:w-full ${sizeClasses[size]} ${className}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Text as="h3" size="lg" weight="medium">
|
||||
{title}
|
||||
</Text>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
<span className="sr-only">إغلاق</span>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
}
|
||||
|
||||
interface ConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: 'danger' | 'warning' | 'info';
|
||||
config?: Partial<LayoutConfig>;
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = "تأكيد",
|
||||
cancelText = "إلغاء",
|
||||
variant = 'info',
|
||||
config = {},
|
||||
}: ConfirmModalProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (variant) {
|
||||
case 'danger':
|
||||
return (
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg className="h-6 w-6 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
case 'warning':
|
||||
return (
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-yellow-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg className="h-6 w-6 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<svg className="h-6 w-6 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title=""
|
||||
size="sm"
|
||||
showCloseButton={false}
|
||||
config={config}
|
||||
>
|
||||
<div className="sm:flex sm:items-start" dir={layoutConfig.direction}>
|
||||
{getIcon()}
|
||||
<div className="mt-3 text-center sm:mt-0 sm:mr-4 sm:text-right">
|
||||
<Text as="h3" size="lg" weight="medium" className="mb-2">
|
||||
{title}
|
||||
</Text>
|
||||
<Text color="secondary" className="mb-4">
|
||||
{message}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-4 sm:flex w-full sm:flex-row space-x-2">
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
variant={variant === 'danger' ? 'danger' : 'primary'}
|
||||
className="w-full sm:w-auto sm:ml-3"
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
className="mt-3 w-full sm:mt-0 sm:w-auto"
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
171
app/components/ui/MultiSelect.tsx
Normal file
171
app/components/ui/MultiSelect.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Text } from "./Text";
|
||||
|
||||
interface Option {
|
||||
value: string | number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface MultiSelectProps {
|
||||
name?: string;
|
||||
label?: string;
|
||||
options: Option[];
|
||||
value: (string | number)[];
|
||||
onChange: (values: (string | number)[]) => void;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function MultiSelect({
|
||||
name,
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "اختر العناصر...",
|
||||
error,
|
||||
required,
|
||||
disabled,
|
||||
className = ""
|
||||
}: MultiSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleToggleOption = (optionValue: string | number) => {
|
||||
if (disabled) return;
|
||||
|
||||
const newValue = value.includes(optionValue)
|
||||
? value.filter(v => v !== optionValue)
|
||||
: [...value, optionValue];
|
||||
|
||||
onChange(newValue);
|
||||
};
|
||||
|
||||
const handleRemoveItem = (optionValue: string | number) => {
|
||||
if (disabled) return;
|
||||
onChange(value.filter(v => v !== optionValue));
|
||||
};
|
||||
|
||||
const selectedOptions = options.filter(option => value.includes(option.value));
|
||||
const displayText = selectedOptions.length > 0
|
||||
? `تم اختيار ${selectedOptions.length} عنصر`
|
||||
: placeholder;
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} ref={containerRef}>
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{label}
|
||||
{required && <span className="text-red-500 mr-1">*</span>}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Hidden input for form submission */}
|
||||
{name && (
|
||||
<input
|
||||
type="hidden"
|
||||
name={name}
|
||||
value={JSON.stringify(value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Selected items display */}
|
||||
{selectedOptions.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{selectedOptions.map((option) => (
|
||||
<span
|
||||
key={option.value}
|
||||
className="inline-flex items-center px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded-full"
|
||||
>
|
||||
{option.label}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveItem(option.value)}
|
||||
className="mr-1 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dropdown trigger */}
|
||||
<div
|
||||
className={`w-full px-3 py-2 border rounded-lg shadow-sm cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white ${
|
||||
error
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300'
|
||||
} ${disabled ? 'bg-gray-50 cursor-not-allowed' : ''}`}
|
||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={selectedOptions.length > 0 ? 'text-gray-900' : 'text-gray-500'}>
|
||||
{displayText}
|
||||
</span>
|
||||
<svg
|
||||
className={`h-5 w-5 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dropdown menu */}
|
||||
{isOpen && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-60 overflow-auto">
|
||||
{options.length === 0 ? (
|
||||
<div className="px-3 py-2 text-gray-500 text-sm">
|
||||
لا توجد خيارات متاحة
|
||||
</div>
|
||||
) : (
|
||||
options.map((option) => {
|
||||
const isSelected = value.includes(option.value);
|
||||
return (
|
||||
<div
|
||||
key={option.value}
|
||||
className={`px-3 py-2 cursor-pointer hover:bg-gray-50 flex items-center justify-between ${
|
||||
isSelected ? 'bg-blue-50 text-blue-700' : 'text-gray-900'
|
||||
}`}
|
||||
onClick={() => handleToggleOption(option.value)}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{isSelected && (
|
||||
<svg className="h-4 w-4 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text size="sm" color="error" className="mt-1">
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
app/components/ui/SearchInput.tsx
Normal file
79
app/components/ui/SearchInput.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input } from './Input';
|
||||
import { defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface SearchInputProps {
|
||||
placeholder?: string;
|
||||
onSearch: (query: string) => void;
|
||||
debounceMs?: number;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
placeholder = "البحث...",
|
||||
onSearch,
|
||||
debounceMs = 300,
|
||||
className = '',
|
||||
config = {},
|
||||
initialValue = '',
|
||||
}: SearchInputProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
const [query, setQuery] = useState(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onSearch(query);
|
||||
}, debounceMs);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, onSearch, debounceMs]);
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`} dir={layoutConfig.direction}>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<svg
|
||||
className="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pr-10"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => setQuery('')}
|
||||
className="absolute inset-y-0 left-0 pl-3 flex items-center"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 text-gray-400 hover:text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
app/components/ui/Select.tsx
Normal file
97
app/components/ui/Select.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { SelectHTMLAttributes, forwardRef } from 'react';
|
||||
import { getFormInputClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'className'> {
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
fullWidth?: boolean;
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(({
|
||||
className = '',
|
||||
config = {},
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
fullWidth = true,
|
||||
options,
|
||||
placeholder,
|
||||
id,
|
||||
...props
|
||||
}, ref) => {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
const inputClasses = getFormInputClasses(!!error);
|
||||
|
||||
const fullWidthClass = fullWidth ? 'w-full' : '';
|
||||
const inputId = id || `select-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return (
|
||||
<div className={`${fullWidthClass} ${className}`} dir={layoutConfig.direction}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={`block text-sm font-medium mb-2 ${error ? 'text-red-700' : 'text-gray-700'} ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<select
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`${inputClasses} appearance-none bg-white`}
|
||||
dir={layoutConfig.direction}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Custom dropdown arrow */}
|
||||
<div className={`absolute inset-y-0 ${layoutConfig.direction === 'rtl' ? 'left-0 pl-3' : 'right-0 pr-3'} flex items-center pointer-events-none`}>
|
||||
<svg className="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className={`mt-2 text-sm text-red-600 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{helperText && !error && (
|
||||
<p className={`mt-2 text-sm text-gray-500 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Select.displayName = 'Select';
|
||||
73
app/components/ui/Text.tsx
Normal file
73
app/components/ui/Text.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { getArabicTextClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface TextProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
size?: 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl';
|
||||
weight?: 'light' | 'normal' | 'medium' | 'semibold' | 'bold';
|
||||
color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'muted';
|
||||
align?: 'left' | 'center' | 'right' | 'justify';
|
||||
as?: 'p' | 'span' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
|
||||
}
|
||||
|
||||
export function Text({
|
||||
children,
|
||||
className = '',
|
||||
config = {},
|
||||
size = 'base',
|
||||
weight = 'normal',
|
||||
color = 'primary',
|
||||
align,
|
||||
as: Component = 'p'
|
||||
}: TextProps) {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
base: 'text-base',
|
||||
lg: 'text-lg',
|
||||
xl: 'text-xl',
|
||||
'2xl': 'text-2xl',
|
||||
'3xl': 'text-3xl',
|
||||
'4xl': 'text-4xl',
|
||||
};
|
||||
|
||||
const weightClasses = {
|
||||
light: 'font-light',
|
||||
normal: 'font-normal',
|
||||
medium: 'font-medium',
|
||||
semibold: 'font-semibold',
|
||||
bold: 'font-bold',
|
||||
};
|
||||
|
||||
const colorClasses = {
|
||||
primary: 'text-gray-900 dark:text-gray-100',
|
||||
secondary: 'text-gray-600 dark:text-gray-400',
|
||||
success: 'text-green-600 dark:text-green-400',
|
||||
warning: 'text-amber-600 dark:text-amber-400',
|
||||
error: 'text-red-600 dark:text-red-400',
|
||||
muted: 'text-gray-500 dark:text-gray-500',
|
||||
};
|
||||
|
||||
const alignClasses = {
|
||||
left: layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left',
|
||||
center: 'text-center',
|
||||
right: layoutConfig.direction === 'rtl' ? 'text-left' : 'text-right',
|
||||
justify: 'text-justify',
|
||||
};
|
||||
|
||||
const alignClass = align ? alignClasses[align] : (layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left');
|
||||
const arabicClasses = getArabicTextClasses(size as any);
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={`${arabicClasses} ${sizeClasses[size]} ${weightClasses[weight]} ${colorClasses[color]} ${alignClass} ${className}`}
|
||||
dir={layoutConfig.direction}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
72
app/components/ui/Textarea.tsx
Normal file
72
app/components/ui/Textarea.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { TextareaHTMLAttributes, forwardRef } from 'react';
|
||||
import { getFormInputClasses, defaultLayoutConfig, type LayoutConfig } from '~/lib/layout-utils';
|
||||
|
||||
interface TextareaProps extends Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'className'> {
|
||||
className?: string;
|
||||
config?: Partial<LayoutConfig>;
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
fullWidth?: boolean;
|
||||
resize?: 'none' | 'vertical' | 'horizontal' | 'both';
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(({
|
||||
className = '',
|
||||
config = {},
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
fullWidth = true,
|
||||
resize = 'vertical',
|
||||
id,
|
||||
...props
|
||||
}, ref) => {
|
||||
const layoutConfig = { ...defaultLayoutConfig, ...config };
|
||||
const inputClasses = getFormInputClasses(!!error);
|
||||
|
||||
const fullWidthClass = fullWidth ? 'w-full' : '';
|
||||
const resizeClass = {
|
||||
none: 'resize-none',
|
||||
vertical: 'resize-y',
|
||||
horizontal: 'resize-x',
|
||||
both: 'resize',
|
||||
}[resize];
|
||||
|
||||
const inputId = id || `textarea-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return (
|
||||
<div className={`${fullWidthClass} ${className}`} dir={layoutConfig.direction}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={`block text-sm font-medium mb-2 ${error ? 'text-red-700' : 'text-gray-700'} ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`${inputClasses} ${resizeClass} min-h-[80px]`}
|
||||
dir={layoutConfig.direction}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className={`mt-2 text-sm text-red-600 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{helperText && !error && (
|
||||
<p className={`mt-2 text-sm text-gray-500 ${layoutConfig.direction === 'rtl' ? 'text-right' : 'text-left'}`}>
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Textarea.displayName = 'Textarea';
|
||||
10
app/components/ui/index.ts
Normal file
10
app/components/ui/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { Text } from './Text';
|
||||
export { Button } from './Button';
|
||||
export { Input } from './Input';
|
||||
export { AutocompleteInput } from './AutocompleteInput';
|
||||
export { Card, CardHeader, CardBody, CardFooter } from './Card';
|
||||
export { DataTable, Pagination } from './DataTable';
|
||||
export { SearchInput } from './SearchInput';
|
||||
export { Modal, ConfirmModal } from './Modal';
|
||||
export { Select } from './Select';
|
||||
export { MultiSelect } from './MultiSelect';
|
||||
217
app/components/users/UserForm.tsx
Normal file
217
app/components/users/UserForm.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Form } from '@remix-run/react';
|
||||
import { Input, Button, Select, Text } from '~/components/ui';
|
||||
import { validateUser } from '~/lib/validation';
|
||||
import { AUTH_LEVELS, USER_STATUS } from '~/types/auth';
|
||||
import type { UserWithoutPassword } from '~/types/database';
|
||||
|
||||
interface UserFormProps {
|
||||
user?: UserWithoutPassword;
|
||||
onSubmit: (data: FormData) => void;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
currentUserAuthLevel: number;
|
||||
}
|
||||
|
||||
export function UserForm({
|
||||
user,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading = false,
|
||||
currentUserAuthLevel,
|
||||
}: UserFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: user?.name || '',
|
||||
username: user?.username || '',
|
||||
email: user?.email || '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
authLevel: user?.authLevel || AUTH_LEVELS.USER,
|
||||
status: user?.status || USER_STATUS.ACTIVE,
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [touched, setTouched] = useState<Record<string, boolean>>({});
|
||||
|
||||
const isEditing = !!user;
|
||||
|
||||
// Validate form data
|
||||
useEffect(() => {
|
||||
const validationData = {
|
||||
name: formData.name,
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
authLevel: formData.authLevel,
|
||||
status: formData.status,
|
||||
};
|
||||
|
||||
// Only validate password for new users or when password is provided
|
||||
if (!isEditing || formData.password) {
|
||||
validationData.password = formData.password;
|
||||
}
|
||||
|
||||
const validation = validateUser(validationData);
|
||||
|
||||
// Add confirm password validation
|
||||
const newErrors = { ...validation.errors };
|
||||
if (!isEditing || formData.password) {
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'كلمات المرور غير متطابقة';
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
}, [formData, isEditing]);
|
||||
|
||||
const handleInputChange = (field: string, value: string | number) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setTouched(prev => ({ ...prev, [field]: true }));
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Mark all fields as touched
|
||||
const allFields = Object.keys(formData);
|
||||
setTouched(allFields.reduce((acc, field) => ({ ...acc, [field]: true }), {}));
|
||||
|
||||
// Check if form is valid
|
||||
if (Object.keys(errors).length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create FormData
|
||||
const submitData = new FormData();
|
||||
submitData.append('name', formData.name);
|
||||
submitData.append('username', formData.username);
|
||||
submitData.append('email', formData.email);
|
||||
submitData.append('authLevel', formData.authLevel.toString());
|
||||
submitData.append('status', formData.status);
|
||||
|
||||
if (!isEditing || formData.password) {
|
||||
submitData.append('password', formData.password);
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
submitData.append('userId', user.id.toString());
|
||||
submitData.append('_action', 'update');
|
||||
} else {
|
||||
submitData.append('_action', 'create');
|
||||
}
|
||||
|
||||
onSubmit(submitData);
|
||||
};
|
||||
|
||||
// Get available auth levels based on current user's level
|
||||
const getAuthLevelOptions = () => {
|
||||
const options = [];
|
||||
|
||||
if (currentUserAuthLevel === AUTH_LEVELS.SUPERADMIN) {
|
||||
options.push({ value: AUTH_LEVELS.SUPERADMIN, label: 'مدير عام' });
|
||||
}
|
||||
|
||||
if (currentUserAuthLevel <= AUTH_LEVELS.ADMIN) {
|
||||
options.push({ value: AUTH_LEVELS.ADMIN, label: 'مدير' });
|
||||
}
|
||||
|
||||
options.push({ value: AUTH_LEVELS.USER, label: 'مستخدم' });
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ value: USER_STATUS.ACTIVE, label: 'نشط' },
|
||||
{ value: USER_STATUS.INACTIVE, label: 'غير نشط' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="الاسم"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
error={touched.name ? errors.name : undefined}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="اسم المستخدم"
|
||||
value={formData.username}
|
||||
onChange={(e) => handleInputChange('username', e.target.value)}
|
||||
error={touched.username ? errors.username : undefined}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="البريد الإلكتروني"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
error={touched.email ? errors.email : undefined}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={isEditing ? "كلمة المرور الجديدة (اختياري)" : "كلمة المرور"}
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||
error={touched.password ? errors.password : undefined}
|
||||
required={!isEditing}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="تأكيد كلمة المرور"
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
|
||||
error={touched.confirmPassword ? errors.confirmPassword : undefined}
|
||||
required={!isEditing || !!formData.password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Select
|
||||
label="مستوى الصلاحية"
|
||||
value={formData.authLevel}
|
||||
onChange={(e) => handleInputChange('authLevel', parseInt(e.target.value))}
|
||||
options={getAuthLevelOptions()}
|
||||
error={touched.authLevel ? errors.authLevel : undefined}
|
||||
required
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="الحالة"
|
||||
value={formData.status}
|
||||
onChange={(e) => handleInputChange('status', e.target.value)}
|
||||
options={statusOptions}
|
||||
error={touched.status ? errors.status : undefined}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-start space-x-3 space-x-reverse pt-4">
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
loading={loading}
|
||||
disabled={Object.keys(errors).length > 0}
|
||||
>
|
||||
{isEditing ? 'تحديث المستخدم' : 'إنشاء المستخدم'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
إلغاء
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
233
app/components/users/UserList.tsx
Normal file
233
app/components/users/UserList.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useState, memo } from 'react';
|
||||
import { Form } from '@remix-run/react';
|
||||
import { DataTable, Pagination, Button, Text, ConfirmModal } from '~/components/ui';
|
||||
import { getAuthLevelName, getStatusName } from '~/lib/user-utils';
|
||||
import { useSettings } from '~/contexts/SettingsContext';
|
||||
import { AUTH_LEVELS } from '~/types/auth';
|
||||
import type { UserWithoutPassword } from '~/types/database';
|
||||
|
||||
interface UserListProps {
|
||||
users: UserWithoutPassword[];
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onEdit: (user: UserWithoutPassword) => void;
|
||||
onDelete: (userId: number) => void;
|
||||
onToggleStatus: (userId: number) => void;
|
||||
currentUserAuthLevel: number;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const UserList = memo(function UserList({
|
||||
users,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleStatus,
|
||||
currentUserAuthLevel,
|
||||
loading = false,
|
||||
}: UserListProps) {
|
||||
const { formatDate } = useSettings();
|
||||
const [deleteModal, setDeleteModal] = useState<{
|
||||
isOpen: boolean;
|
||||
user: UserWithoutPassword | null;
|
||||
}>({ isOpen: false, user: null });
|
||||
|
||||
const [statusModal, setStatusModal] = useState<{
|
||||
isOpen: boolean;
|
||||
user: UserWithoutPassword | null;
|
||||
}>({ isOpen: false, user: null });
|
||||
|
||||
const handleDeleteClick = (user: UserWithoutPassword) => {
|
||||
setDeleteModal({ isOpen: true, user });
|
||||
};
|
||||
|
||||
const handleStatusClick = (user: UserWithoutPassword) => {
|
||||
setStatusModal({ isOpen: true, user });
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (deleteModal.user) {
|
||||
onDelete(deleteModal.user.id);
|
||||
}
|
||||
setDeleteModal({ isOpen: false, user: null });
|
||||
};
|
||||
|
||||
const handleStatusConfirm = () => {
|
||||
if (statusModal.user) {
|
||||
onToggleStatus(statusModal.user.id);
|
||||
}
|
||||
setStatusModal({ isOpen: false, user: null });
|
||||
};
|
||||
|
||||
const canEditUser = (user: UserWithoutPassword) => {
|
||||
// Superadmin can edit anyone
|
||||
if (currentUserAuthLevel === AUTH_LEVELS.SUPERADMIN) return true;
|
||||
|
||||
// Admin cannot edit superadmin
|
||||
if (currentUserAuthLevel === AUTH_LEVELS.ADMIN && user.authLevel === AUTH_LEVELS.SUPERADMIN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const canDeleteUser = (user: UserWithoutPassword) => {
|
||||
// Same rules as edit
|
||||
return canEditUser(user);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'الاسم',
|
||||
sortable: true,
|
||||
render: (user: UserWithoutPassword) => (
|
||||
<div>
|
||||
<Text weight="medium">{user.name}</Text>
|
||||
<Text size="sm" color="secondary">@{user.username}</Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
header: 'البريد الإلكتروني',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'authLevel',
|
||||
header: 'مستوى الصلاحية',
|
||||
render: (user: UserWithoutPassword) => {
|
||||
const levelName = getAuthLevelName(user.authLevel);
|
||||
const colorClass = user.authLevel === AUTH_LEVELS.SUPERADMIN
|
||||
? 'text-purple-600 bg-purple-100'
|
||||
: user.authLevel === AUTH_LEVELS.ADMIN
|
||||
? 'text-blue-600 bg-blue-100'
|
||||
: 'text-gray-600 bg-gray-100';
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
|
||||
{levelName}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'الحالة',
|
||||
render: (user: UserWithoutPassword) => {
|
||||
const statusName = getStatusName(user.status);
|
||||
const colorClass = user.status === 'active'
|
||||
? 'text-green-600 bg-green-100'
|
||||
: 'text-red-600 bg-red-100';
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${colorClass}`}>
|
||||
{statusName}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'createdDate',
|
||||
header: 'تاريخ الإنشاء',
|
||||
sortable: true,
|
||||
render: (user: UserWithoutPassword) => (
|
||||
<Text size="sm" color="secondary">
|
||||
{formatDate(user.createdDate)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'الإجراءات',
|
||||
render: (user: UserWithoutPassword) => (
|
||||
<div className="flex items-center space-x-2 space-x-reverse">
|
||||
{canEditUser(user) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onEdit(user)}
|
||||
className='w-24'
|
||||
>
|
||||
تعديل
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canEditUser(user) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleStatusClick(user)}
|
||||
className='w-24'
|
||||
>
|
||||
{user.status === 'active' ? 'إلغاء تفعيل' : 'تفعيل'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canDeleteUser(user) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => handleDeleteClick(user)}
|
||||
className='w-24'
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
emptyMessage="لا توجد مستخدمين"
|
||||
/>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={deleteModal.isOpen}
|
||||
onClose={() => setDeleteModal({ isOpen: false, user: null })}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="تأكيد الحذف"
|
||||
message={`هل أنت متأكد من حذف المستخدم "${deleteModal.user?.name}"؟ هذا الإجراء لا يمكن التراجع عنه.`}
|
||||
confirmText="حذف"
|
||||
cancelText="إلغاء"
|
||||
variant="danger"
|
||||
/>
|
||||
|
||||
{/* Status Toggle Confirmation Modal */}
|
||||
<ConfirmModal
|
||||
isOpen={statusModal.isOpen}
|
||||
onClose={() => setStatusModal({ isOpen: false, user: null })}
|
||||
onConfirm={handleStatusConfirm}
|
||||
title={statusModal.user?.status === 'active' ? 'إلغاء تفعيل المستخدم' : 'تفعيل المستخدم'}
|
||||
message={
|
||||
statusModal.user?.status === 'active'
|
||||
? `هل أنت متأكد من إلغاء تفعيل المستخدم "${statusModal.user?.name}"؟`
|
||||
: `هل أنت متأكد من تفعيل المستخدم "${statusModal.user?.name}"؟`
|
||||
}
|
||||
confirmText={statusModal.user?.status === 'active' ? 'إلغاء تفعيل' : 'تفعيل'}
|
||||
cancelText="إلغاء"
|
||||
variant={statusModal.user?.status === 'active' ? 'warning' : 'info'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
388
app/components/vehicles/VehicleDetailsView.tsx
Normal file
388
app/components/vehicles/VehicleDetailsView.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Flex } from "~/components/layout/Flex";
|
||||
import { useSettings } from "~/contexts/SettingsContext";
|
||||
import { TRANSMISSION_TYPES, FUEL_TYPES, USE_TYPES, BODY_TYPES } from "~/lib/constants";
|
||||
import type { VehicleWithOwner, VehicleWithRelations } from "~/types/database";
|
||||
|
||||
interface VehicleDetailsViewProps {
|
||||
vehicle: VehicleWithOwner | VehicleWithRelations;
|
||||
onEdit?: () => void;
|
||||
onClose?: () => void;
|
||||
isLoadingVisits?: boolean;
|
||||
}
|
||||
|
||||
export function VehicleDetailsView({ vehicle, onEdit, onClose, isLoadingVisits }: VehicleDetailsViewProps) {
|
||||
const { formatDate, formatCurrency, formatNumber } = useSettings();
|
||||
// Helper functions to get display labels
|
||||
const getTransmissionLabel = (value: string) => {
|
||||
return TRANSMISSION_TYPES.find(t => t.value === value)?.label || value;
|
||||
};
|
||||
|
||||
const getFuelLabel = (value: string) => {
|
||||
return FUEL_TYPES.find(f => f.value === value)?.label || value;
|
||||
};
|
||||
|
||||
const getUseTypeLabel = (value: string) => {
|
||||
return USE_TYPES.find(u => u.value === value)?.label || value;
|
||||
};
|
||||
|
||||
const getBodyTypeLabel = (value: string) => {
|
||||
return BODY_TYPES.find(b => b.value === value)?.label || value;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Enhanced Vehicle Information Section */}
|
||||
<div className="bg-gradient-to-r from-green-50 to-emerald-50 p-6 rounded-xl border border-green-100">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-bold text-gray-900 flex items-center">
|
||||
<span className="text-green-600 ml-2">🚗</span>
|
||||
معلومات المركبة
|
||||
</h3>
|
||||
<span className="text-sm text-gray-500 bg-white px-3 py-1 rounded-full">
|
||||
المركبة #{vehicle.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">رقم اللوحة</label>
|
||||
<p className="text-xl font-bold text-gray-900 font-mono tracking-wider">
|
||||
{vehicle.plateNumber}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">الشركة المصنعة</label>
|
||||
<p className="text-lg font-semibold text-gray-900">{vehicle.manufacturer}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">الموديل</label>
|
||||
<p className="text-lg font-semibold text-gray-900">{vehicle.model}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">سنة الصنع</label>
|
||||
<p className="text-lg font-semibold text-gray-900">{vehicle.year}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">نوع الهيكل</label>
|
||||
<p className="text-gray-900">{getBodyTypeLabel(vehicle.bodyType)}</p>
|
||||
</div>
|
||||
|
||||
{vehicle.trim && (
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">الفئة</label>
|
||||
<p className="text-gray-900">{vehicle.trim}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Technical Specifications */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<span className="text-gray-600 text-xl ml-2">⚙️</span>
|
||||
المواصفات التقنية
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">ناقل الحركة</label>
|
||||
<p className="text-gray-900 font-medium">{getTransmissionLabel(vehicle.transmission)}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">نوع الوقود</label>
|
||||
<p className="text-gray-900 font-medium">{getFuelLabel(vehicle.fuel)}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">نوع الاستخدام</label>
|
||||
<p className="text-gray-900 font-medium">{getUseTypeLabel(vehicle.useType)}</p>
|
||||
</div>
|
||||
|
||||
{vehicle.cylinders && (
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">عدد الأسطوانات</label>
|
||||
<p className="text-gray-900 font-medium">{vehicle.cylinders}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vehicle.engineDisplacement && (
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">سعة المحرك</label>
|
||||
<p className="text-gray-900 font-medium">{vehicle.engineDisplacement} لتر</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Owner Information */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<Flex justify="between" align="center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<span className="text-gray-600 text-xl ml-2">👤</span>
|
||||
معلومات المالك
|
||||
</h3>
|
||||
<Link
|
||||
to={`/customers?search=${encodeURIComponent(vehicle.owner.name)}`}
|
||||
target="_blank"
|
||||
className="inline-flex items-center px-3 py-1 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-lg hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<span className="ml-2">👁️</span>
|
||||
عرض تفاصيل المالك
|
||||
</Link>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">اسم المالك</label>
|
||||
<p className="text-lg font-semibold text-gray-900">{vehicle.owner.name}</p>
|
||||
</div>
|
||||
|
||||
{vehicle.owner.phone && (
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">رقم الهاتف</label>
|
||||
<a
|
||||
href={`tel:${vehicle.owner.phone}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
dir="ltr"
|
||||
>
|
||||
📞 {vehicle.owner.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vehicle.owner.email && (
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">البريد الإلكتروني</label>
|
||||
<a
|
||||
href={`mailto:${vehicle.owner.email}`}
|
||||
className="text-blue-600 hover:text-blue-800 font-medium"
|
||||
dir="ltr"
|
||||
>
|
||||
✉️ {vehicle.owner.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vehicle.owner.address && (
|
||||
<div className="bg-blue-50 p-4 rounded-lg md:col-span-2 lg:col-span-3">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">العنوان</label>
|
||||
<p className="text-gray-900">{vehicle.owner.address}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maintenance Status */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<Flex justify="between" align="center">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<span className="text-gray-600 text-xl ml-2">🔧</span>
|
||||
حالة الصيانة
|
||||
</h3>
|
||||
<Link
|
||||
to={`/maintenance-visits?vehicleId=${vehicle.id}`}
|
||||
target="_blank"
|
||||
className="inline-flex items-center px-3 py-1 text-sm font-medium text-green-600 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
|
||||
>
|
||||
<span className="ml-2">📋</span>
|
||||
عرض جميع زيارات الصيانة
|
||||
</Link>
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">آخر زيارة صيانة</label>
|
||||
<p className="text-gray-900 font-medium">
|
||||
{vehicle.lastVisitDate
|
||||
? formatDate(vehicle.lastVisitDate)
|
||||
: <span className="text-gray-400">لا توجد زيارات</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{vehicle.suggestedNextVisitDate && (
|
||||
<div className="bg-orange-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">الزيارة المقترحة التالية</label>
|
||||
<p className="text-orange-600 font-bold">
|
||||
{formatDate(vehicle.suggestedNextVisitDate)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">تاريخ التسجيل</label>
|
||||
<p className="text-gray-900">
|
||||
{formatDate(vehicle.createdDate)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<label className="block text-sm font-medium text-gray-600 mb-1">آخر تحديث</label>
|
||||
<p className="text-gray-900">
|
||||
{formatDate(vehicle.updateDate)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Maintenance Visits
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
|
||||
<span className="text-gray-600 text-xl ml-2">🔧</span>
|
||||
آخر زيارات الصيانة
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{isLoadingVisits ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 text-4xl mb-4">🔧</div>
|
||||
<h4 className="text-lg font-medium text-gray-900 mb-2">جاري تحميل زيارات الصيانة...</h4>
|
||||
<div className="flex items-center justify-center">
|
||||
<svg className="animate-spin h-6 w-6 text-gray-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
) : !('maintenanceVisits' in vehicle) || !vehicle.maintenanceVisits || vehicle.maintenanceVisits.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 text-4xl mb-4">🔧</div>
|
||||
<h4 className="text-lg font-medium text-gray-900 mb-2">لا توجد زيارات صيانة</h4>
|
||||
<p className="text-gray-500 mb-4">لم يتم تسجيل أي زيارات صيانة لهذه المركبة بعد</p>
|
||||
<Link
|
||||
to="/maintenance-visits"
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
تسجيل زيارة صيانة جديدة
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{('maintenanceVisits' in vehicle ? vehicle.maintenanceVisits : []).slice(0, 3).map((visit: any) => (
|
||||
<div
|
||||
key={visit.id}
|
||||
className="bg-gray-50 rounded-lg p-4 border border-gray-200 hover:border-green-300 hover:bg-green-50 transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-gray-900 text-lg">
|
||||
{(() => {
|
||||
try {
|
||||
const jobs = JSON.parse(visit.maintenanceJobs);
|
||||
return jobs.length > 1
|
||||
? `${jobs.length} أعمال صيانة`
|
||||
: jobs[0]?.job || 'نوع صيانة غير محدد';
|
||||
} catch {
|
||||
return 'نوع صيانة غير محدد';
|
||||
}
|
||||
})()}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500">زيارة #{visit.id}</p>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-lg font-bold text-green-600">
|
||||
{formatCurrency(visit.cost)}
|
||||
</div>
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${visit.paymentStatus === "paid"
|
||||
? 'bg-green-100 text-green-800'
|
||||
: visit.paymentStatus === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{visit.paymentStatus === 'paid' ? 'مدفوع' :
|
||||
visit.paymentStatus === 'pending' ? 'معلق' : 'غير مدفوع'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">تاريخ الزيارة:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{formatDate(visit.visitDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">عداد الكيلومترات:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{visit.kilometers ? formatNumber(visit.kilometers) : 'غير محدد'} كم
|
||||
</span>
|
||||
</div>
|
||||
{visit.description && (
|
||||
<div className="md:col-span-2">
|
||||
<span className="text-gray-600">الوصف:</span>
|
||||
<p className="text-gray-900 mt-1">{visit.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{('maintenanceVisits' in vehicle ? vehicle.maintenanceVisits : []).length > 3 && (
|
||||
<div className="text-center py-4 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
عرض 3 من أصل {('maintenanceVisits' in vehicle ? vehicle.maintenanceVisits : []).length} زيارة صيانة
|
||||
</p>
|
||||
<Link
|
||||
to={`/maintenance-visits?vehicleId=${vehicle.id}`}
|
||||
target="_blank"
|
||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-green-600 bg-green-50 border border-green-200 rounded-lg hover:bg-green-100 transition-colors"
|
||||
>
|
||||
<span className="ml-2">📋</span>
|
||||
عرض جميع الزيارات ({('maintenanceVisits' in vehicle ? vehicle.maintenanceVisits : []).length})
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{(onEdit || onClose) && (
|
||||
<div className="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-200">
|
||||
{onEdit && (
|
||||
<Button
|
||||
onClick={onEdit}
|
||||
className="bg-blue-600 hover:bg-blue-700 flex-1 sm:flex-none"
|
||||
>
|
||||
<span className="ml-2">✏️</span>
|
||||
تعديل المركبة
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
إغلاق
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
576
app/components/vehicles/VehicleForm.tsx
Normal file
576
app/components/vehicles/VehicleForm.tsx
Normal file
@@ -0,0 +1,576 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Input } from "~/components/ui/Input";
|
||||
import { AutocompleteInput } from "~/components/ui/AutocompleteInput";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Flex } from "~/components/layout/Flex";
|
||||
import { TRANSMISSION_TYPES, FUEL_TYPES, USE_TYPES, VALIDATION } from "~/lib/constants";
|
||||
import type { Vehicle } from "~/types/database";
|
||||
|
||||
interface VehicleFormProps {
|
||||
vehicle?: Vehicle;
|
||||
customers: { id: number; name: string; phone?: string | null }[];
|
||||
onCancel: () => void;
|
||||
errors?: Record<string, string>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function VehicleForm({
|
||||
vehicle,
|
||||
customers,
|
||||
onCancel,
|
||||
errors = {},
|
||||
isLoading,
|
||||
}: VehicleFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
plateNumber: vehicle?.plateNumber || "",
|
||||
bodyType: vehicle?.bodyType || "",
|
||||
manufacturer: vehicle?.manufacturer || "",
|
||||
model: vehicle?.model || "",
|
||||
trim: vehicle?.trim || "",
|
||||
year: vehicle?.year?.toString() || "",
|
||||
transmission: vehicle?.transmission || "",
|
||||
fuel: vehicle?.fuel || "",
|
||||
cylinders: vehicle?.cylinders?.toString() || "",
|
||||
engineDisplacement: vehicle?.engineDisplacement?.toString() || "",
|
||||
useType: vehicle?.useType || "",
|
||||
ownerId: vehicle?.ownerId?.toString() || "",
|
||||
});
|
||||
|
||||
// Car dataset state
|
||||
const [manufacturers, setManufacturers] = useState<string[]>([]);
|
||||
const [models, setModels] = useState<{model: string; bodyType: string}[]>([]);
|
||||
const [isLoadingManufacturers, setIsLoadingManufacturers] = useState(false);
|
||||
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
||||
|
||||
// Autocomplete state
|
||||
const [manufacturerSearchValue, setManufacturerSearchValue] = useState(vehicle?.manufacturer || "");
|
||||
const [modelSearchValue, setModelSearchValue] = useState(vehicle?.model || "");
|
||||
const [ownerSearchValue, setOwnerSearchValue] = useState(() => {
|
||||
if (vehicle?.ownerId) {
|
||||
const owner = customers.find(c => c.id === vehicle.ownerId);
|
||||
return owner ? owner.name : "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
// Load manufacturers on component mount
|
||||
useEffect(() => {
|
||||
const loadManufacturers = async () => {
|
||||
setIsLoadingManufacturers(true);
|
||||
try {
|
||||
const response = await fetch('/api/car-dataset?action=manufacturers');
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setManufacturers(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading manufacturers:', error);
|
||||
} finally {
|
||||
setIsLoadingManufacturers(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadManufacturers();
|
||||
}, []);
|
||||
|
||||
// Load models when manufacturer changes
|
||||
useEffect(() => {
|
||||
if (formData.manufacturer) {
|
||||
const loadModels = async () => {
|
||||
setIsLoadingModels(true);
|
||||
try {
|
||||
const response = await fetch(`/api/car-dataset?action=models&manufacturer=${encodeURIComponent(formData.manufacturer)}`);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setModels(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading models:', error);
|
||||
} finally {
|
||||
setIsLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadModels();
|
||||
} else {
|
||||
setModels([]);
|
||||
}
|
||||
}, [formData.manufacturer]);
|
||||
|
||||
// Create autocomplete options
|
||||
const manufacturerOptions = manufacturers.map(manufacturer => ({
|
||||
value: manufacturer,
|
||||
label: manufacturer,
|
||||
data: manufacturer
|
||||
}));
|
||||
|
||||
const modelOptions = models.map(item => ({
|
||||
value: item.model,
|
||||
label: item.model,
|
||||
data: item
|
||||
}));
|
||||
|
||||
const ownerOptions = customers.map(customer => ({
|
||||
value: customer.name,
|
||||
label: `${customer.name}${customer.phone ? ` - ${customer.phone}` : ''}`,
|
||||
data: customer
|
||||
}));
|
||||
|
||||
// Handle manufacturer selection
|
||||
const handleManufacturerSelect = (option: any) => {
|
||||
const manufacturer = option.data;
|
||||
setManufacturerSearchValue(manufacturer);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
manufacturer,
|
||||
model: "", // Reset model when manufacturer changes
|
||||
bodyType: "" // Reset body type when manufacturer changes
|
||||
}));
|
||||
setModelSearchValue(""); // Reset model search
|
||||
};
|
||||
|
||||
// Handle model selection
|
||||
const handleModelSelect = (option: any) => {
|
||||
const modelData = option.data;
|
||||
setModelSearchValue(modelData.model);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
model: modelData.model,
|
||||
bodyType: modelData.bodyType // Auto-set body type from dataset
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle owner selection from autocomplete
|
||||
const handleOwnerSelect = (option: any) => {
|
||||
const customer = option.data;
|
||||
setOwnerSearchValue(customer.name);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
ownerId: customer.id.toString()
|
||||
}));
|
||||
};
|
||||
|
||||
// Reset form data when vehicle changes
|
||||
useEffect(() => {
|
||||
if (vehicle) {
|
||||
const owner = customers.find(c => c.id === vehicle.ownerId);
|
||||
setFormData({
|
||||
plateNumber: vehicle.plateNumber || "",
|
||||
bodyType: vehicle.bodyType || "",
|
||||
manufacturer: vehicle.manufacturer || "",
|
||||
model: vehicle.model || "",
|
||||
trim: vehicle.trim || "",
|
||||
year: vehicle.year?.toString() || "",
|
||||
transmission: vehicle.transmission || "",
|
||||
fuel: vehicle.fuel || "",
|
||||
cylinders: vehicle.cylinders?.toString() || "",
|
||||
engineDisplacement: vehicle.engineDisplacement?.toString() || "",
|
||||
useType: vehicle.useType || "",
|
||||
ownerId: vehicle.ownerId?.toString() || "",
|
||||
});
|
||||
setManufacturerSearchValue(vehicle.manufacturer || "");
|
||||
setModelSearchValue(vehicle.model || "");
|
||||
setOwnerSearchValue(owner ? owner.name : "");
|
||||
} else {
|
||||
setFormData({
|
||||
plateNumber: "",
|
||||
bodyType: "",
|
||||
manufacturer: "",
|
||||
model: "",
|
||||
trim: "",
|
||||
year: "",
|
||||
transmission: "",
|
||||
fuel: "",
|
||||
cylinders: "",
|
||||
engineDisplacement: "",
|
||||
useType: "",
|
||||
ownerId: "",
|
||||
});
|
||||
setManufacturerSearchValue("");
|
||||
setModelSearchValue("");
|
||||
setOwnerSearchValue("");
|
||||
}
|
||||
}, [vehicle, customers]);
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const isEditing = !!vehicle;
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<Form method="post" className="space-y-6">
|
||||
<input
|
||||
type="hidden"
|
||||
name="_action"
|
||||
value={isEditing ? "update" : "create"}
|
||||
/>
|
||||
{isEditing && (
|
||||
<input type="hidden" name="id" value={vehicle.id} />
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Plate Number */}
|
||||
<div>
|
||||
<label htmlFor="plateNumber" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
رقم اللوحة *
|
||||
</label>
|
||||
<Input
|
||||
id="plateNumber"
|
||||
name="plateNumber"
|
||||
type="text"
|
||||
value={formData.plateNumber}
|
||||
onChange={(e) => handleInputChange("plateNumber", e.target.value)}
|
||||
placeholder="أدخل رقم اللوحة"
|
||||
error={errors.plateNumber}
|
||||
required
|
||||
disabled={isLoading}
|
||||
dir="ltr"
|
||||
/>
|
||||
{errors.plateNumber && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.plateNumber}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manufacturer with Autocomplete */}
|
||||
<div>
|
||||
<AutocompleteInput
|
||||
label="الشركة المصنعة *"
|
||||
placeholder={isLoadingManufacturers ? "جاري التحميل..." : "ابدأ بكتابة اسم الشركة المصنعة..."}
|
||||
value={manufacturerSearchValue}
|
||||
onChange={setManufacturerSearchValue}
|
||||
onSelect={handleManufacturerSelect}
|
||||
options={manufacturerOptions}
|
||||
error={errors.manufacturer}
|
||||
required
|
||||
disabled={isLoading || isLoadingManufacturers}
|
||||
/>
|
||||
{/* Hidden input for form submission */}
|
||||
<input
|
||||
type="hidden"
|
||||
name="manufacturer"
|
||||
value={formData.manufacturer}
|
||||
/>
|
||||
{formData.manufacturer && manufacturerSearchValue && (
|
||||
<p className="mt-1 text-sm text-green-600">
|
||||
✓ تم اختيار الشركة المصنعة: {manufacturerSearchValue}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model with Autocomplete */}
|
||||
<div>
|
||||
<AutocompleteInput
|
||||
label="الموديل *"
|
||||
placeholder={
|
||||
!formData.manufacturer
|
||||
? "اختر الشركة المصنعة أولاً"
|
||||
: isLoadingModels
|
||||
? "جاري التحميل..."
|
||||
: "ابدأ بكتابة اسم الموديل..."
|
||||
}
|
||||
value={modelSearchValue}
|
||||
onChange={setModelSearchValue}
|
||||
onSelect={handleModelSelect}
|
||||
options={modelOptions}
|
||||
error={errors.model}
|
||||
required
|
||||
disabled={isLoading || isLoadingModels || !formData.manufacturer}
|
||||
/>
|
||||
{/* Hidden input for form submission */}
|
||||
<input
|
||||
type="hidden"
|
||||
name="model"
|
||||
value={formData.model}
|
||||
/>
|
||||
{formData.model && modelSearchValue && (
|
||||
<p className="mt-1 text-sm text-green-600">
|
||||
✓ تم اختيار الموديل: {modelSearchValue}
|
||||
</p>
|
||||
)}
|
||||
{!formData.manufacturer && (
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
يرجى اختيار الشركة المصنعة أولاً
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body Type (Auto-filled, Read-only) */}
|
||||
<div>
|
||||
<label htmlFor="bodyType" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
نوع الهيكل *
|
||||
</label>
|
||||
<Input
|
||||
id="bodyType"
|
||||
name="bodyType"
|
||||
type="text"
|
||||
value={formData.bodyType}
|
||||
placeholder={formData.model ? "سيتم تعبئته تلقائياً" : "اختر الموديل أولاً"}
|
||||
error={errors.bodyType}
|
||||
required
|
||||
readOnly={true}
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
{formData.bodyType && (
|
||||
<p className="mt-1 text-sm text-blue-600">
|
||||
ℹ️ تم تعبئة نوع الهيكل تلقائياً من قاعدة البيانات
|
||||
</p>
|
||||
)}
|
||||
{errors.bodyType && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.bodyType}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trim */}
|
||||
<div>
|
||||
<label htmlFor="trim" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
الفئة
|
||||
</label>
|
||||
<Input
|
||||
id="trim"
|
||||
name="trim"
|
||||
type="text"
|
||||
value={formData.trim}
|
||||
onChange={(e) => handleInputChange("trim", e.target.value)}
|
||||
placeholder="أدخل الفئة (اختياري)"
|
||||
error={errors.trim}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.trim && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.trim}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Year */}
|
||||
<div>
|
||||
<label htmlFor="year" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
سنة الصنع *
|
||||
</label>
|
||||
<Input
|
||||
id="year"
|
||||
name="year"
|
||||
type="number"
|
||||
min={VALIDATION.MIN_YEAR}
|
||||
max={VALIDATION.MAX_YEAR}
|
||||
value={formData.year}
|
||||
onChange={(e) => handleInputChange("year", e.target.value)}
|
||||
placeholder={`${VALIDATION.MIN_YEAR} - ${currentYear}`}
|
||||
error={errors.year}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.year && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.year}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transmission */}
|
||||
<div>
|
||||
<label htmlFor="transmission" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
ناقل الحركة *
|
||||
</label>
|
||||
<select
|
||||
id="transmission"
|
||||
name="transmission"
|
||||
value={formData.transmission}
|
||||
onChange={(e) => handleInputChange("transmission", e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
w-full px-3 py-2 border rounded-lg shadow-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||
disabled:bg-gray-50 disabled:text-gray-500
|
||||
${errors.transmission
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<option value="">اختر ناقل الحركة</option>
|
||||
{TRANSMISSION_TYPES.map((transmission) => (
|
||||
<option key={transmission.value} value={transmission.value}>
|
||||
{transmission.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.transmission && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.transmission}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fuel */}
|
||||
<div>
|
||||
<label htmlFor="fuel" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
نوع الوقود *
|
||||
</label>
|
||||
<select
|
||||
id="fuel"
|
||||
name="fuel"
|
||||
value={formData.fuel}
|
||||
onChange={(e) => handleInputChange("fuel", e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
w-full px-3 py-2 border rounded-lg shadow-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||
disabled:bg-gray-50 disabled:text-gray-500
|
||||
${errors.fuel
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<option value="">اختر نوع الوقود</option>
|
||||
{FUEL_TYPES.map((fuel) => (
|
||||
<option key={fuel.value} value={fuel.value}>
|
||||
{fuel.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.fuel && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.fuel}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cylinders */}
|
||||
<div>
|
||||
<label htmlFor="cylinders" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
عدد الأسطوانات
|
||||
</label>
|
||||
<Input
|
||||
id="cylinders"
|
||||
name="cylinders"
|
||||
type="number"
|
||||
min="1"
|
||||
max={VALIDATION.MAX_CYLINDERS}
|
||||
value={formData.cylinders}
|
||||
onChange={(e) => handleInputChange("cylinders", e.target.value)}
|
||||
placeholder="عدد الأسطوانات (اختياري)"
|
||||
error={errors.cylinders}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.cylinders && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.cylinders}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Engine Displacement */}
|
||||
<div>
|
||||
<label htmlFor="engineDisplacement" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
سعة المحرك (لتر)
|
||||
</label>
|
||||
<Input
|
||||
id="engineDisplacement"
|
||||
name="engineDisplacement"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.1"
|
||||
max={VALIDATION.MAX_ENGINE_DISPLACEMENT}
|
||||
value={formData.engineDisplacement}
|
||||
onChange={(e) => handleInputChange("engineDisplacement", e.target.value)}
|
||||
placeholder="سعة المحرك (اختياري)"
|
||||
error={errors.engineDisplacement}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.engineDisplacement && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.engineDisplacement}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Use Type */}
|
||||
<div>
|
||||
<label htmlFor="useType" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
نوع الاستخدام *
|
||||
</label>
|
||||
<select
|
||||
id="useType"
|
||||
name="useType"
|
||||
value={formData.useType}
|
||||
onChange={(e) => handleInputChange("useType", e.target.value)}
|
||||
required
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
w-full px-3 py-2 border rounded-lg shadow-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
|
||||
disabled:bg-gray-50 disabled:text-gray-500
|
||||
${errors.useType
|
||||
? 'border-red-300 focus:ring-red-500 focus:border-red-500'
|
||||
: 'border-gray-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<option value="">اختر نوع الاستخدام</option>
|
||||
{USE_TYPES.map((useType) => (
|
||||
<option key={useType.value} value={useType.value}>
|
||||
{useType.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.useType && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.useType}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Owner with Autocomplete */}
|
||||
<div>
|
||||
<AutocompleteInput
|
||||
label="المالك *"
|
||||
placeholder="ابدأ بكتابة اسم المالك..."
|
||||
value={ownerSearchValue}
|
||||
onChange={setOwnerSearchValue}
|
||||
onSelect={handleOwnerSelect}
|
||||
options={ownerOptions}
|
||||
error={errors.ownerId}
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{/* Hidden input for form submission */}
|
||||
<input
|
||||
type="hidden"
|
||||
name="ownerId"
|
||||
value={formData.ownerId}
|
||||
/>
|
||||
{formData.ownerId && ownerSearchValue && (
|
||||
<p className="mt-1 text-sm text-green-600">
|
||||
✓ تم اختيار المالك: {ownerSearchValue}
|
||||
</p>
|
||||
)}
|
||||
{!formData.ownerId && ownerSearchValue && (
|
||||
<p className="mt-1 text-sm text-amber-600">
|
||||
يرجى اختيار المالك من القائمة المنسدلة
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Actions */}
|
||||
<Flex justify="end" className="pt-4 gap-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
className="w-20"
|
||||
>
|
||||
إلغاء
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || !formData.plateNumber.trim() || !formData.ownerId}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isLoading
|
||||
? (isEditing ? "جاري التحديث..." : "جاري الإنشاء...")
|
||||
: (isEditing ? "تحديث المركبة" : "إنشاء المركبة")
|
||||
}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
272
app/components/vehicles/VehicleList.tsx
Normal file
272
app/components/vehicles/VehicleList.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { Form, Link } from "@remix-run/react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { DataTable, Pagination } from "~/components/ui/DataTable";
|
||||
import { Button } from "~/components/ui/Button";
|
||||
import { Flex } from "~/components/layout/Flex";
|
||||
import { useSettings } from "~/contexts/SettingsContext";
|
||||
import { TRANSMISSION_TYPES, FUEL_TYPES, USE_TYPES, BODY_TYPES } from "~/lib/constants";
|
||||
import type { VehicleWithOwner } from "~/types/database";
|
||||
|
||||
interface VehicleListProps {
|
||||
vehicles: VehicleWithOwner[];
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onEditVehicle: (vehicle: VehicleWithOwner) => void;
|
||||
onViewVehicle?: (vehicle: VehicleWithOwner) => void;
|
||||
isLoading: boolean;
|
||||
actionData?: any;
|
||||
}
|
||||
|
||||
export function VehicleList({
|
||||
vehicles,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
onEditVehicle,
|
||||
onViewVehicle,
|
||||
isLoading,
|
||||
actionData,
|
||||
}: VehicleListProps) {
|
||||
const { formatDate } = useSettings();
|
||||
const [deletingVehicleId, setDeletingVehicleId] = useState<number | null>(null);
|
||||
|
||||
// Reset deleting state when delete action completes
|
||||
useEffect(() => {
|
||||
if (actionData?.success && actionData.action === "delete") {
|
||||
setDeletingVehicleId(null);
|
||||
}
|
||||
}, [actionData]);
|
||||
|
||||
// Helper functions to get display labels
|
||||
const getTransmissionLabel = (value: string) => {
|
||||
return TRANSMISSION_TYPES.find(t => t.value === value)?.label || value;
|
||||
};
|
||||
|
||||
const getFuelLabel = (value: string) => {
|
||||
return FUEL_TYPES.find(f => f.value === value)?.label || value;
|
||||
};
|
||||
|
||||
const getUseTypeLabel = (value: string) => {
|
||||
return USE_TYPES.find(u => u.value === value)?.label || value;
|
||||
};
|
||||
|
||||
const getBodyTypeLabel = (value: string) => {
|
||||
return BODY_TYPES.find(b => b.value === value)?.label || value;
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: "plateNumber",
|
||||
header: "رقم اللوحة",
|
||||
render: (vehicle: VehicleWithOwner) => (
|
||||
<div>
|
||||
<Link
|
||||
to={`/vehicles/${vehicle.id}`}
|
||||
className="font-mono text-lg font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{vehicle.plateNumber}
|
||||
</Link>
|
||||
<div className="text-sm text-gray-500">
|
||||
المركبة رقم: {vehicle.id}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "vehicle",
|
||||
header: "تفاصيل المركبة",
|
||||
render: (vehicle: VehicleWithOwner) => (
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">
|
||||
{vehicle.manufacturer} {vehicle.model}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{vehicle.year} • {getBodyTypeLabel(vehicle.bodyType)}
|
||||
</div>
|
||||
{vehicle.trim && (
|
||||
<div className="text-sm text-gray-500">
|
||||
فئة: {vehicle.trim}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "specifications",
|
||||
header: "المواصفات",
|
||||
render: (vehicle: VehicleWithOwner) => (
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-gray-900">
|
||||
{getTransmissionLabel(vehicle.transmission)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{getFuelLabel(vehicle.fuel)}
|
||||
</div>
|
||||
{vehicle.cylinders && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{vehicle.cylinders} أسطوانة
|
||||
</div>
|
||||
)}
|
||||
{vehicle.engineDisplacement && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{vehicle.engineDisplacement}L
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "owner",
|
||||
header: "المالك",
|
||||
render: (vehicle: VehicleWithOwner) => (
|
||||
<div>
|
||||
<Link
|
||||
to={`/customers/${vehicle.owner.id}`}
|
||||
className="font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{vehicle.owner.name}
|
||||
</Link>
|
||||
{vehicle.owner.phone && (
|
||||
<div className="text-sm text-gray-500" dir="ltr">
|
||||
{vehicle.owner.phone}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "useType",
|
||||
header: "نوع الاستخدام",
|
||||
render: (vehicle: VehicleWithOwner) => (
|
||||
<div className="text-sm text-gray-900">
|
||||
{getUseTypeLabel(vehicle.useType)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "maintenance",
|
||||
header: "الصيانة",
|
||||
render: (vehicle: VehicleWithOwner) => (
|
||||
<div className="space-y-1">
|
||||
{vehicle.lastVisitDate ? (
|
||||
<div className="text-sm text-gray-900">
|
||||
آخر زيارة: {formatDate(vehicle.lastVisitDate)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">
|
||||
لا توجد زيارات
|
||||
</div>
|
||||
)}
|
||||
{vehicle.suggestedNextVisitDate && (
|
||||
<div className="text-sm text-orange-600">
|
||||
الزيارة التالية: {formatDate(vehicle.suggestedNextVisitDate)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "createdDate",
|
||||
header: "تاريخ التسجيل",
|
||||
render: (vehicle: VehicleWithOwner) => (
|
||||
<div className="text-sm text-gray-600">
|
||||
{formatDate(vehicle.createdDate)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "الإجراءات",
|
||||
render: (vehicle: VehicleWithOwner) => (
|
||||
<Flex className="flex-wrap gap-2">
|
||||
{onViewVehicle ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onViewVehicle(vehicle)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
عرض
|
||||
</Button>
|
||||
) : (
|
||||
<Link to={`/vehicles/${vehicle.id}`}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
عرض
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onEditVehicle(vehicle)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
تعديل
|
||||
</Button>
|
||||
|
||||
<Form method="post" className="inline">
|
||||
<input type="hidden" name="_action" value="delete" />
|
||||
<input type="hidden" name="id" value={vehicle.id} />
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-red-600 border-red-300 hover:bg-red-50"
|
||||
disabled={isLoading || deletingVehicleId === vehicle.id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (window.confirm("هل أنت متأكد من حذف هذه المركبة؟")) {
|
||||
setDeletingVehicleId(vehicle.id);
|
||||
(e.target as HTMLButtonElement).form?.submit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deletingVehicleId === vehicle.id ? "جاري الحذف..." : "حذف"}
|
||||
</Button>
|
||||
</Form>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-lg shadow-sm border">
|
||||
{vehicles.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 text-lg mb-2">🚗</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
لا توجد مركبات
|
||||
</h3>
|
||||
<p className="text-gray-500">
|
||||
لم يتم العثور على أي مركبات. قم بإضافة مركبة جديدة للبدء.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
data={vehicles}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
emptyMessage="لم يتم العثور على أي مركبات"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
app/contexts/SettingsContext.tsx
Normal file
163
app/contexts/SettingsContext.tsx
Normal 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
18
app/entry.client.tsx
Normal 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
140
app/entry.server.tsx
Normal 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
17
app/hooks/useDebounce.ts
Normal 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;
|
||||
}
|
||||
219
app/hooks/useFormValidation.ts
Normal file
219
app/hooks/useFormValidation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
39
app/lib/__tests__/auth-integration.test.ts
Normal file
39
app/lib/__tests__/auth-integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
app/lib/__tests__/auth.test.ts
Normal file
58
app/lib/__tests__/auth.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
437
app/lib/__tests__/customer-management.test.ts
Normal file
437
app/lib/__tests__/customer-management.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
293
app/lib/__tests__/customer-routes-integration.test.ts
Normal file
293
app/lib/__tests__/customer-routes-integration.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
109
app/lib/__tests__/customer-validation.test.ts
Normal file
109
app/lib/__tests__/customer-validation.test.ts
Normal 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('اسم العميل مطلوب');
|
||||
});
|
||||
});
|
||||
});
|
||||
278
app/lib/__tests__/expense-management.test.ts
Normal file
278
app/lib/__tests__/expense-management.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
458
app/lib/__tests__/financial-reporting.test.ts
Normal file
458
app/lib/__tests__/financial-reporting.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
201
app/lib/__tests__/form-validation.test.ts
Normal file
201
app/lib/__tests__/form-validation.test.ts
Normal 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('فئة المصروف مطلوبة');
|
||||
});
|
||||
});
|
||||
});
|
||||
434
app/lib/__tests__/maintenance-visit-management.test.ts
Normal file
434
app/lib/__tests__/maintenance-visit-management.test.ts
Normal 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()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
425
app/lib/__tests__/maintenance-visit-validation.test.ts
Normal file
425
app/lib/__tests__/maintenance-visit-validation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
107
app/lib/__tests__/route-protection-integration.test.ts
Normal file
107
app/lib/__tests__/route-protection-integration.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
299
app/lib/__tests__/user-management.test.ts
Normal file
299
app/lib/__tests__/user-management.test.ts
Normal 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('لا يمكن حذف آخر مدير عام في النظام');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
222
app/lib/__tests__/validation-utils.test.ts
Normal file
222
app/lib/__tests__/validation-utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
443
app/lib/__tests__/vehicle-management.test.ts
Normal file
443
app/lib/__tests__/vehicle-management.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
379
app/lib/__tests__/vehicle-validation.test.ts
Normal file
379
app/lib/__tests__/vehicle-validation.test.ts
Normal 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
51
app/lib/auth-constants.ts
Normal 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;
|
||||
220
app/lib/auth-helpers.server.ts
Normal file
220
app/lib/auth-helpers.server.ts
Normal 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);
|
||||
}
|
||||
173
app/lib/auth-middleware.server.ts
Normal file
173
app/lib/auth-middleware.server.ts
Normal 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
113
app/lib/auth.server.ts
Normal 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),
|
||||
},
|
||||
});
|
||||
}
|
||||
257
app/lib/car-dataset-management.server.ts
Normal file
257
app/lib/car-dataset-management.server.ts
Normal 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
173
app/lib/constants.ts
Normal 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;
|
||||
326
app/lib/customer-management.server.ts
Normal file
326
app/lib/customer-management.server.ts
Normal 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
405
app/lib/db.server.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
176
app/lib/expense-management.server.ts
Normal file
176
app/lib/expense-management.server.ts
Normal 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;
|
||||
}
|
||||
361
app/lib/financial-reporting.server.ts
Normal file
361
app/lib/financial-reporting.server.ts
Normal 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
274
app/lib/form-validation.ts
Normal 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
146
app/lib/layout-utils.ts
Normal 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];
|
||||
}
|
||||
220
app/lib/maintenance-type-management.server.ts
Normal file
220
app/lib/maintenance-type-management.server.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
365
app/lib/maintenance-visit-management.server.ts
Normal file
365
app/lib/maintenance-visit-management.server.ts
Normal 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' },
|
||||
});
|
||||
}
|
||||
209
app/lib/settings-management.server.ts
Normal file
209
app/lib/settings-management.server.ts
Normal 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
309
app/lib/table-utils.ts
Normal 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;
|
||||
};
|
||||
341
app/lib/user-management.server.ts
Normal file
341
app/lib/user-management.server.ts
Normal 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
27
app/lib/user-utils.ts
Normal 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
291
app/lib/validation-utils.ts
Normal 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
342
app/lib/validation.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
368
app/lib/vehicle-management.server.ts
Normal file
368
app/lib/vehicle-management.server.ts
Normal 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
84
app/root.tsx
Normal 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
22
app/routes/_index.tsx
Normal 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");
|
||||
}
|
||||
}
|
||||
66
app/routes/admin.enable-signup.tsx
Normal file
66
app/routes/admin.enable-signup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
app/routes/api.car-dataset.ts
Normal file
44
app/routes/api.car-dataset.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
20
app/routes/api.customers.search.tsx
Normal file
20
app/routes/api.customers.search.tsx
Normal 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
387
app/routes/customers.tsx
Normal 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
233
app/routes/dashboard.tsx
Normal 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
498
app/routes/expenses.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
396
app/routes/financial-reports.tsx
Normal file
396
app/routes/financial-reports.tsx
Normal 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
66
app/routes/financial.tsx
Normal 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
Reference in New Issue
Block a user