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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

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

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

View File

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