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