uup
This commit is contained in:
39
app/lib/__tests__/auth-integration.test.ts
Normal file
39
app/lib/__tests__/auth-integration.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
// Test the actual bcrypt functionality without importing our modules
|
||||
describe("Authentication Integration", () => {
|
||||
describe("Password Hashing Integration", () => {
|
||||
it("should hash and verify passwords using bcrypt", async () => {
|
||||
const password = "testpassword123";
|
||||
|
||||
// Hash the password
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
expect(hashedPassword).toBeDefined();
|
||||
expect(hashedPassword).not.toBe(password);
|
||||
expect(hashedPassword.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify correct password
|
||||
const isValidPassword = await bcrypt.compare(password, hashedPassword);
|
||||
expect(isValidPassword).toBe(true);
|
||||
|
||||
// Verify incorrect password
|
||||
const isInvalidPassword = await bcrypt.compare("wrongpassword", hashedPassword);
|
||||
expect(isInvalidPassword).toBe(false);
|
||||
});
|
||||
|
||||
it("should generate different hashes for the same password", async () => {
|
||||
const password = "testpassword123";
|
||||
|
||||
const hash1 = await bcrypt.hash(password, 12);
|
||||
const hash2 = await bcrypt.hash(password, 12);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
|
||||
// But both should verify correctly
|
||||
expect(await bcrypt.compare(password, hash1)).toBe(true);
|
||||
expect(await bcrypt.compare(password, hash2)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
app/lib/__tests__/auth.test.ts
Normal file
58
app/lib/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { hasPermission, canAccessUserManagement } from "../auth-helpers.server";
|
||||
import { AUTH_LEVELS } from "~/types/auth";
|
||||
|
||||
// Mock the database
|
||||
vi.mock("../db.server", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock auth.server to avoid session secret requirement
|
||||
vi.mock("../auth.server", () => ({
|
||||
hashPassword: vi.fn(),
|
||||
verifyPassword: vi.fn(),
|
||||
createUserSession: vi.fn(),
|
||||
getUserSession: vi.fn(),
|
||||
getUserId: vi.fn(),
|
||||
requireUserId: vi.fn(),
|
||||
getUser: vi.fn(),
|
||||
requireUser: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Authentication System", () => {
|
||||
|
||||
describe("Authorization Helpers", () => {
|
||||
it("should check permissions correctly", () => {
|
||||
// Superadmin should have access to everything
|
||||
expect(hasPermission(AUTH_LEVELS.SUPERADMIN, AUTH_LEVELS.SUPERADMIN)).toBe(true);
|
||||
expect(hasPermission(AUTH_LEVELS.SUPERADMIN, AUTH_LEVELS.ADMIN)).toBe(true);
|
||||
expect(hasPermission(AUTH_LEVELS.SUPERADMIN, AUTH_LEVELS.USER)).toBe(true);
|
||||
|
||||
// Admin should have access to admin and user levels
|
||||
expect(hasPermission(AUTH_LEVELS.ADMIN, AUTH_LEVELS.SUPERADMIN)).toBe(false);
|
||||
expect(hasPermission(AUTH_LEVELS.ADMIN, AUTH_LEVELS.ADMIN)).toBe(true);
|
||||
expect(hasPermission(AUTH_LEVELS.ADMIN, AUTH_LEVELS.USER)).toBe(true);
|
||||
|
||||
// User should only have access to user level
|
||||
expect(hasPermission(AUTH_LEVELS.USER, AUTH_LEVELS.SUPERADMIN)).toBe(false);
|
||||
expect(hasPermission(AUTH_LEVELS.USER, AUTH_LEVELS.ADMIN)).toBe(false);
|
||||
expect(hasPermission(AUTH_LEVELS.USER, AUTH_LEVELS.USER)).toBe(true);
|
||||
});
|
||||
|
||||
it("should check user management access correctly", () => {
|
||||
expect(canAccessUserManagement(AUTH_LEVELS.SUPERADMIN)).toBe(true);
|
||||
expect(canAccessUserManagement(AUTH_LEVELS.ADMIN)).toBe(true);
|
||||
expect(canAccessUserManagement(AUTH_LEVELS.USER)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
437
app/lib/__tests__/customer-management.test.ts
Normal file
437
app/lib/__tests__/customer-management.test.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// Mock Prisma first
|
||||
vi.mock('../db.server', () => ({
|
||||
prisma: {
|
||||
customer: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getCustomers,
|
||||
createCustomer,
|
||||
updateCustomer,
|
||||
deleteCustomer,
|
||||
getCustomerById,
|
||||
getCustomersForSelect,
|
||||
searchCustomers,
|
||||
getCustomerStats
|
||||
} from '../customer-management.server';
|
||||
import { prisma } from '../db.server';
|
||||
|
||||
const mockPrisma = prisma as any;
|
||||
|
||||
describe('Customer Management Server', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getCustomers', () => {
|
||||
it('should return customers with pagination', async () => {
|
||||
const mockCustomers = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
address: 'الرياض',
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
vehicles: [],
|
||||
maintenanceVisits: [],
|
||||
},
|
||||
];
|
||||
|
||||
mockPrisma.customer.findMany.mockResolvedValue(mockCustomers);
|
||||
mockPrisma.customer.count.mockResolvedValue(1);
|
||||
|
||||
const result = await getCustomers('', 1, 10);
|
||||
|
||||
expect(result).toEqual({
|
||||
customers: mockCustomers,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
expect(mockPrisma.customer.findMany).toHaveBeenCalledWith({
|
||||
where: {},
|
||||
include: {
|
||||
vehicles: {
|
||||
select: {
|
||||
id: true,
|
||||
plateNumber: true,
|
||||
manufacturer: true,
|
||||
model: true,
|
||||
year: true,
|
||||
lastVisitDate: true,
|
||||
suggestedNextVisitDate: true,
|
||||
},
|
||||
},
|
||||
maintenanceVisits: {
|
||||
select: {
|
||||
id: true,
|
||||
visitDate: true,
|
||||
cost: true,
|
||||
maintenanceType: true,
|
||||
},
|
||||
orderBy: { visitDate: 'desc' },
|
||||
take: 5,
|
||||
},
|
||||
},
|
||||
orderBy: { createdDate: 'desc' },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle search query', async () => {
|
||||
mockPrisma.customer.findMany.mockResolvedValue([]);
|
||||
mockPrisma.customer.count.mockResolvedValue(0);
|
||||
|
||||
await getCustomers('أحمد', 1, 10);
|
||||
|
||||
expect(mockPrisma.customer.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: { contains: 'أحمد' } },
|
||||
{ phone: { contains: 'أحمد' } },
|
||||
{ email: { contains: 'أحمد' } },
|
||||
{ address: { contains: 'أحمد' } },
|
||||
],
|
||||
},
|
||||
include: expect.any(Object),
|
||||
orderBy: { createdDate: 'desc' },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCustomer', () => {
|
||||
it('should create a new customer successfully', async () => {
|
||||
const customerData = {
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
address: 'الرياض',
|
||||
};
|
||||
|
||||
const mockCustomer = {
|
||||
id: 1,
|
||||
...customerData,
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
};
|
||||
|
||||
mockPrisma.customer.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.customer.create.mockResolvedValue(mockCustomer);
|
||||
|
||||
const result = await createCustomer(customerData);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
customer: mockCustomer,
|
||||
});
|
||||
expect(mockPrisma.customer.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
address: 'الرياض',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if phone already exists', async () => {
|
||||
const customerData = {
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
};
|
||||
|
||||
const existingCustomer = {
|
||||
id: 2,
|
||||
name: 'محمد أحمد',
|
||||
phone: '0501234567',
|
||||
email: null,
|
||||
address: null,
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
};
|
||||
|
||||
mockPrisma.customer.findFirst.mockResolvedValue(existingCustomer);
|
||||
|
||||
const result = await createCustomer(customerData);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'رقم الهاتف موجود بالفعل',
|
||||
});
|
||||
expect(mockPrisma.customer.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error if email already exists', async () => {
|
||||
const customerData = {
|
||||
name: 'أحمد محمد',
|
||||
email: 'ahmed@example.com',
|
||||
};
|
||||
|
||||
const existingCustomer = {
|
||||
id: 2,
|
||||
name: 'محمد أحمد',
|
||||
phone: null,
|
||||
email: 'ahmed@example.com',
|
||||
address: null,
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
};
|
||||
|
||||
mockPrisma.customer.findFirst.mockResolvedValue(existingCustomer);
|
||||
|
||||
const result = await createCustomer(customerData);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'البريد الإلكتروني موجود بالفعل',
|
||||
});
|
||||
expect(mockPrisma.customer.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCustomer', () => {
|
||||
it('should update customer successfully', async () => {
|
||||
const customerId = 1;
|
||||
const updateData = {
|
||||
name: 'أحمد محمد المحدث',
|
||||
phone: '0509876543',
|
||||
};
|
||||
|
||||
const existingCustomer = {
|
||||
id: customerId,
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: null,
|
||||
address: null,
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
};
|
||||
|
||||
const updatedCustomer = {
|
||||
...existingCustomer,
|
||||
...updateData,
|
||||
updateDate: new Date(),
|
||||
};
|
||||
|
||||
mockPrisma.customer.findUnique.mockResolvedValue(existingCustomer);
|
||||
mockPrisma.customer.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.customer.update.mockResolvedValue(updatedCustomer);
|
||||
|
||||
const result = await updateCustomer(customerId, updateData);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
customer: updatedCustomer,
|
||||
});
|
||||
expect(mockPrisma.customer.update).toHaveBeenCalledWith({
|
||||
where: { id: customerId },
|
||||
data: {
|
||||
name: 'أحمد محمد المحدث',
|
||||
phone: '0509876543',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if customer not found', async () => {
|
||||
const customerId = 999;
|
||||
const updateData = { name: 'أحمد محمد' };
|
||||
|
||||
mockPrisma.customer.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await updateCustomer(customerId, updateData);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'العميل غير موجود',
|
||||
});
|
||||
expect(mockPrisma.customer.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCustomer', () => {
|
||||
it('should delete customer successfully when no relationships exist', async () => {
|
||||
const customerId = 1;
|
||||
const existingCustomer = {
|
||||
id: customerId,
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: null,
|
||||
address: null,
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
vehicles: [],
|
||||
maintenanceVisits: [],
|
||||
};
|
||||
|
||||
mockPrisma.customer.findUnique.mockResolvedValue(existingCustomer);
|
||||
mockPrisma.customer.delete.mockResolvedValue(existingCustomer);
|
||||
|
||||
const result = await deleteCustomer(customerId);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(mockPrisma.customer.delete).toHaveBeenCalledWith({
|
||||
where: { id: customerId },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if customer has vehicles', async () => {
|
||||
const customerId = 1;
|
||||
const existingCustomer = {
|
||||
id: customerId,
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: null,
|
||||
address: null,
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
vehicles: [{ id: 1, plateNumber: 'ABC-123' }],
|
||||
maintenanceVisits: [],
|
||||
};
|
||||
|
||||
mockPrisma.customer.findUnique.mockResolvedValue(existingCustomer);
|
||||
|
||||
const result = await deleteCustomer(customerId);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'لا يمكن حذف العميل لأنه يملك 1 مركبة. يرجى حذف المركبات أولاً',
|
||||
});
|
||||
expect(mockPrisma.customer.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error if customer has maintenance visits', async () => {
|
||||
const customerId = 1;
|
||||
const existingCustomer = {
|
||||
id: customerId,
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: null,
|
||||
address: null,
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
vehicles: [],
|
||||
maintenanceVisits: [{ id: 1, visitDate: new Date() }],
|
||||
};
|
||||
|
||||
mockPrisma.customer.findUnique.mockResolvedValue(existingCustomer);
|
||||
|
||||
const result = await deleteCustomer(customerId);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'لا يمكن حذف العميل لأنه لديه 1 زيارة صيانة. يرجى حذف الزيارات أولاً',
|
||||
});
|
||||
expect(mockPrisma.customer.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error if customer not found', async () => {
|
||||
const customerId = 999;
|
||||
|
||||
mockPrisma.customer.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await deleteCustomer(customerId);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'العميل غير موجود',
|
||||
});
|
||||
expect(mockPrisma.customer.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchCustomers', () => {
|
||||
it('should return empty array for short queries', async () => {
|
||||
const result = await searchCustomers('a');
|
||||
expect(result).toEqual([]);
|
||||
expect(mockPrisma.customer.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should search customers by name, phone, and email', async () => {
|
||||
const mockCustomers = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
},
|
||||
];
|
||||
|
||||
mockPrisma.customer.findMany.mockResolvedValue(mockCustomers);
|
||||
|
||||
const result = await searchCustomers('أحمد', 10);
|
||||
|
||||
expect(result).toEqual(mockCustomers);
|
||||
expect(mockPrisma.customer.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: { contains: 'أحمد' } },
|
||||
{ phone: { contains: 'أحمد' } },
|
||||
{ email: { contains: 'أحمد' } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCustomerStats', () => {
|
||||
it('should return customer statistics', async () => {
|
||||
const customerId = 1;
|
||||
const mockCustomer = {
|
||||
id: customerId,
|
||||
name: 'أحمد محمد',
|
||||
vehicles: [{ id: 1 }, { id: 2 }],
|
||||
maintenanceVisits: [
|
||||
{ cost: 100, visitDate: new Date('2024-01-15') },
|
||||
{ cost: 200, visitDate: new Date('2024-01-10') },
|
||||
{ cost: 150, visitDate: new Date('2024-01-05') },
|
||||
],
|
||||
};
|
||||
|
||||
mockPrisma.customer.findUnique.mockResolvedValue(mockCustomer);
|
||||
|
||||
const result = await getCustomerStats(customerId);
|
||||
|
||||
expect(result).toEqual({
|
||||
totalVehicles: 2,
|
||||
totalVisits: 3,
|
||||
totalSpent: 450,
|
||||
lastVisitDate: new Date('2024-01-15'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null if customer not found', async () => {
|
||||
const customerId = 999;
|
||||
|
||||
mockPrisma.customer.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await getCustomerStats(customerId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
293
app/lib/__tests__/customer-routes-integration.test.ts
Normal file
293
app/lib/__tests__/customer-routes-integration.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock the auth middleware
|
||||
vi.mock('../auth-middleware.server', () => ({
|
||||
requireAuth: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
authLevel: 1,
|
||||
status: 'active',
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the customer management functions
|
||||
vi.mock('../customer-management.server', () => ({
|
||||
getCustomers: vi.fn(),
|
||||
createCustomer: vi.fn(),
|
||||
updateCustomer: vi.fn(),
|
||||
deleteCustomer: vi.fn(),
|
||||
getCustomerById: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock validation
|
||||
vi.mock('../validation', () => ({
|
||||
validateCustomer: vi.fn(),
|
||||
}));
|
||||
|
||||
import { loader, action } from '../../routes/customers';
|
||||
import { getCustomers, createCustomer, updateCustomer, deleteCustomer } from '../customer-management.server';
|
||||
import { validateCustomer } from '../validation';
|
||||
|
||||
const mockGetCustomers = getCustomers as any;
|
||||
const mockCreateCustomer = createCustomer as any;
|
||||
const mockUpdateCustomer = updateCustomer as any;
|
||||
const mockDeleteCustomer = deleteCustomer as any;
|
||||
const mockValidateCustomer = validateCustomer as any;
|
||||
|
||||
describe('Customer Routes Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('loader', () => {
|
||||
it('should load customers with default pagination', async () => {
|
||||
const mockCustomers = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
address: 'الرياض',
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
vehicles: [],
|
||||
maintenanceVisits: [],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetCustomers.mockResolvedValue({
|
||||
customers: mockCustomers,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
const request = new Request('http://localhost:3000/customers');
|
||||
const response = await loader({ request, params: {}, context: {} });
|
||||
const data = await response.json();
|
||||
|
||||
expect(mockGetCustomers).toHaveBeenCalledWith('', 1, 10);
|
||||
expect(data.customers).toEqual(mockCustomers);
|
||||
expect(data.total).toBe(1);
|
||||
expect(data.totalPages).toBe(1);
|
||||
expect(data.currentPage).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle search and pagination parameters', async () => {
|
||||
mockGetCustomers.mockResolvedValue({
|
||||
customers: [],
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
const request = new Request('http://localhost:3000/customers?search=أحمد&page=2&limit=20');
|
||||
await loader({ request, params: {}, context: {} });
|
||||
|
||||
expect(mockGetCustomers).toHaveBeenCalledWith('أحمد', 2, 20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('action', () => {
|
||||
it('should create customer successfully', async () => {
|
||||
const mockCustomer = {
|
||||
id: 1,
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
address: 'الرياض',
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
};
|
||||
|
||||
mockValidateCustomer.mockReturnValue({
|
||||
isValid: true,
|
||||
errors: {},
|
||||
});
|
||||
|
||||
mockCreateCustomer.mockResolvedValue({
|
||||
success: true,
|
||||
customer: mockCustomer,
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('_action', 'create');
|
||||
formData.append('name', 'أحمد محمد');
|
||||
formData.append('phone', '0501234567');
|
||||
formData.append('email', 'ahmed@example.com');
|
||||
formData.append('address', 'الرياض');
|
||||
|
||||
const request = new Request('http://localhost:3000/customers', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await action({ request, params: {}, context: {} });
|
||||
const data = await response.json();
|
||||
|
||||
expect(mockValidateCustomer).toHaveBeenCalledWith({
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
address: 'الرياض',
|
||||
});
|
||||
|
||||
expect(mockCreateCustomer).toHaveBeenCalledWith({
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
address: 'الرياض',
|
||||
});
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.customer).toEqual(mockCustomer);
|
||||
expect(data.action).toBe('create');
|
||||
expect(data.message).toBe('تم إنشاء العميل بنجاح');
|
||||
});
|
||||
|
||||
it('should return validation errors for invalid data', async () => {
|
||||
mockValidateCustomer.mockReturnValue({
|
||||
isValid: false,
|
||||
errors: {
|
||||
name: 'اسم العميل مطلوب',
|
||||
email: 'البريد الإلكتروني غير صحيح',
|
||||
},
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('_action', 'create');
|
||||
formData.append('name', '');
|
||||
formData.append('email', 'invalid-email');
|
||||
|
||||
const request = new Request('http://localhost:3000/customers', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await action({ request, params: {}, context: {} });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errors).toEqual({
|
||||
name: 'اسم العميل مطلوب',
|
||||
email: 'البريد الإلكتروني غير صحيح',
|
||||
});
|
||||
expect(mockCreateCustomer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update customer successfully', async () => {
|
||||
const mockCustomer = {
|
||||
id: 1,
|
||||
name: 'أحمد محمد المحدث',
|
||||
phone: '0509876543',
|
||||
email: 'ahmed.updated@example.com',
|
||||
address: 'جدة',
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
};
|
||||
|
||||
mockValidateCustomer.mockReturnValue({
|
||||
isValid: true,
|
||||
errors: {},
|
||||
});
|
||||
|
||||
mockUpdateCustomer.mockResolvedValue({
|
||||
success: true,
|
||||
customer: mockCustomer,
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('_action', 'update');
|
||||
formData.append('id', '1');
|
||||
formData.append('name', 'أحمد محمد المحدث');
|
||||
formData.append('phone', '0509876543');
|
||||
formData.append('email', 'ahmed.updated@example.com');
|
||||
formData.append('address', 'جدة');
|
||||
|
||||
const request = new Request('http://localhost:3000/customers', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await action({ request, params: {}, context: {} });
|
||||
const data = await response.json();
|
||||
|
||||
expect(mockUpdateCustomer).toHaveBeenCalledWith(1, {
|
||||
name: 'أحمد محمد المحدث',
|
||||
phone: '0509876543',
|
||||
email: 'ahmed.updated@example.com',
|
||||
address: 'جدة',
|
||||
});
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.customer).toEqual(mockCustomer);
|
||||
expect(data.action).toBe('update');
|
||||
expect(data.message).toBe('تم تحديث العميل بنجاح');
|
||||
});
|
||||
|
||||
it('should delete customer successfully', async () => {
|
||||
mockDeleteCustomer.mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('_action', 'delete');
|
||||
formData.append('id', '1');
|
||||
|
||||
const request = new Request('http://localhost:3000/customers', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await action({ request, params: {}, context: {} });
|
||||
const data = await response.json();
|
||||
|
||||
expect(mockDeleteCustomer).toHaveBeenCalledWith(1);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.action).toBe('delete');
|
||||
expect(data.message).toBe('تم حذف العميل بنجاح');
|
||||
});
|
||||
|
||||
it('should handle delete errors', async () => {
|
||||
mockDeleteCustomer.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'لا يمكن حذف العميل لأنه يملك مركبات',
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('_action', 'delete');
|
||||
formData.append('id', '1');
|
||||
|
||||
const request = new Request('http://localhost:3000/customers', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await action({ request, params: {}, context: {} });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe('لا يمكن حذف العميل لأنه يملك مركبات');
|
||||
});
|
||||
|
||||
it('should handle unknown action', async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('_action', 'unknown');
|
||||
|
||||
const request = new Request('http://localhost:3000/customers', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await action({ request, params: {}, context: {} });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe('إجراء غير صحيح');
|
||||
expect(data.action).toBe('unknown');
|
||||
});
|
||||
});
|
||||
});
|
||||
109
app/lib/__tests__/customer-validation.test.ts
Normal file
109
app/lib/__tests__/customer-validation.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateCustomer } from '../validation';
|
||||
|
||||
describe('Customer Validation', () => {
|
||||
describe('validateCustomer', () => {
|
||||
it('should validate required name field', () => {
|
||||
const result = validateCustomer({ name: '' });
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.name).toBe('اسم العميل مطلوب');
|
||||
});
|
||||
|
||||
it('should validate name length', () => {
|
||||
const longName = 'أ'.repeat(101);
|
||||
const result = validateCustomer({ name: longName });
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.name).toBe('الاسم يجب أن يكون أقل من 100 حرف');
|
||||
});
|
||||
|
||||
it('should validate phone format', () => {
|
||||
const result = validateCustomer({
|
||||
name: 'أحمد محمد',
|
||||
phone: 'invalid-phone'
|
||||
});
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.phone).toBe('رقم الهاتف غير صحيح');
|
||||
});
|
||||
|
||||
it('should accept valid phone formats', () => {
|
||||
const validPhones = [
|
||||
'0501234567',
|
||||
'+966501234567',
|
||||
'050 123 4567',
|
||||
'050-123-4567',
|
||||
'(050) 123-4567',
|
||||
];
|
||||
|
||||
validPhones.forEach(phone => {
|
||||
const result = validateCustomer({
|
||||
name: 'أحمد محمد',
|
||||
phone
|
||||
});
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors.phone).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate email format', () => {
|
||||
const result = validateCustomer({
|
||||
name: 'أحمد محمد',
|
||||
email: 'invalid-email'
|
||||
});
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.email).toBe('البريد الإلكتروني غير صحيح');
|
||||
});
|
||||
|
||||
it('should accept valid email format', () => {
|
||||
const result = validateCustomer({
|
||||
name: 'أحمد محمد',
|
||||
email: 'ahmed@example.com'
|
||||
});
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors.email).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow empty optional fields', () => {
|
||||
const result = validateCustomer({
|
||||
name: 'أحمد محمد',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: ''
|
||||
});
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should validate complete customer data', () => {
|
||||
const result = validateCustomer({
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
address: 'الرياض، المملكة العربية السعودية'
|
||||
});
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle undefined fields gracefully', () => {
|
||||
const result = validateCustomer({
|
||||
name: 'أحمد محمد',
|
||||
phone: undefined,
|
||||
email: undefined,
|
||||
address: undefined
|
||||
});
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should trim whitespace from name', () => {
|
||||
const result = validateCustomer({ name: ' أحمد محمد ' });
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject name with only whitespace', () => {
|
||||
const result = validateCustomer({ name: ' ' });
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.name).toBe('اسم العميل مطلوب');
|
||||
});
|
||||
});
|
||||
});
|
||||
278
app/lib/__tests__/expense-management.test.ts
Normal file
278
app/lib/__tests__/expense-management.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
createExpense,
|
||||
getExpenses,
|
||||
getExpenseById,
|
||||
updateExpense,
|
||||
deleteExpense,
|
||||
getExpenseCategories,
|
||||
getExpensesByCategory,
|
||||
getTotalExpenses
|
||||
} from '../expense-management.server';
|
||||
import { prisma } from '../db.server';
|
||||
|
||||
describe('Expense Management', () => {
|
||||
beforeEach(async () => {
|
||||
// Clean up test data
|
||||
await prisma.expense.deleteMany();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test data
|
||||
await prisma.expense.deleteMany();
|
||||
});
|
||||
|
||||
describe('createExpense', () => {
|
||||
it('should create a new expense', async () => {
|
||||
const expenseData = {
|
||||
description: 'قطع غيار للمحرك',
|
||||
category: 'قطع غيار',
|
||||
amount: 500.00,
|
||||
expenseDate: new Date('2024-01-15'),
|
||||
};
|
||||
|
||||
const expense = await createExpense(expenseData);
|
||||
|
||||
expect(expense).toBeDefined();
|
||||
expect(expense.description).toBe(expenseData.description);
|
||||
expect(expense.category).toBe(expenseData.category);
|
||||
expect(expense.amount).toBe(expenseData.amount);
|
||||
expect(expense.expenseDate).toEqual(expenseData.expenseDate);
|
||||
});
|
||||
|
||||
it('should create expense with current date if no date provided', async () => {
|
||||
const expenseData = {
|
||||
description: 'مصروف عام',
|
||||
category: 'أخرى',
|
||||
amount: 100.00,
|
||||
};
|
||||
|
||||
const expense = await createExpense(expenseData);
|
||||
|
||||
expect(expense).toBeDefined();
|
||||
expect(expense.expenseDate).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpenses', () => {
|
||||
beforeEach(async () => {
|
||||
// Create test expenses
|
||||
await prisma.expense.createMany({
|
||||
data: [
|
||||
{
|
||||
description: 'قطع غيار',
|
||||
category: 'قطع غيار',
|
||||
amount: 500.00,
|
||||
expenseDate: new Date('2024-01-15'),
|
||||
},
|
||||
{
|
||||
description: 'أدوات صيانة',
|
||||
category: 'أدوات',
|
||||
amount: 200.00,
|
||||
expenseDate: new Date('2024-01-10'),
|
||||
},
|
||||
{
|
||||
description: 'إيجار المحل',
|
||||
category: 'إيجار',
|
||||
amount: 3000.00,
|
||||
expenseDate: new Date('2024-01-01'),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all expenses with pagination', async () => {
|
||||
const result = await getExpenses();
|
||||
|
||||
expect(result.expenses).toHaveLength(3);
|
||||
expect(result.total).toBe(3);
|
||||
expect(result.totalPages).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter expenses by search query', async () => {
|
||||
const result = await getExpenses('قطع غيار');
|
||||
|
||||
expect(result.expenses).toHaveLength(1);
|
||||
expect(result.expenses[0].description).toBe('قطع غيار');
|
||||
});
|
||||
|
||||
it('should filter expenses by category', async () => {
|
||||
const result = await getExpenses(undefined, 1, 10, 'أدوات');
|
||||
|
||||
expect(result.expenses).toHaveLength(1);
|
||||
expect(result.expenses[0].category).toBe('أدوات');
|
||||
});
|
||||
|
||||
it('should filter expenses by date range', async () => {
|
||||
const dateFrom = new Date('2024-01-10');
|
||||
const dateTo = new Date('2024-01-20');
|
||||
|
||||
const result = await getExpenses(undefined, 1, 10, undefined, dateFrom, dateTo);
|
||||
|
||||
expect(result.expenses).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle pagination correctly', async () => {
|
||||
const result = await getExpenses(undefined, 1, 2);
|
||||
|
||||
expect(result.expenses).toHaveLength(2);
|
||||
expect(result.totalPages).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpenseById', () => {
|
||||
it('should get expense by ID', async () => {
|
||||
const createdExpense = await prisma.expense.create({
|
||||
data: {
|
||||
description: 'تست مصروف',
|
||||
category: 'أخرى',
|
||||
amount: 100.00,
|
||||
},
|
||||
});
|
||||
|
||||
const expense = await getExpenseById(createdExpense.id);
|
||||
|
||||
expect(expense).toBeDefined();
|
||||
expect(expense?.id).toBe(createdExpense.id);
|
||||
expect(expense?.description).toBe('تست مصروف');
|
||||
});
|
||||
|
||||
it('should return null for non-existent expense', async () => {
|
||||
const expense = await getExpenseById(999);
|
||||
|
||||
expect(expense).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateExpense', () => {
|
||||
it('should update expense successfully', async () => {
|
||||
const createdExpense = await prisma.expense.create({
|
||||
data: {
|
||||
description: 'مصروف قديم',
|
||||
category: 'أخرى',
|
||||
amount: 100.00,
|
||||
},
|
||||
});
|
||||
|
||||
const updateData = {
|
||||
description: 'مصروف محدث',
|
||||
category: 'قطع غيار',
|
||||
amount: 200.00,
|
||||
};
|
||||
|
||||
const updatedExpense = await updateExpense(createdExpense.id, updateData);
|
||||
|
||||
expect(updatedExpense.description).toBe(updateData.description);
|
||||
expect(updatedExpense.category).toBe(updateData.category);
|
||||
expect(updatedExpense.amount).toBe(updateData.amount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteExpense', () => {
|
||||
it('should delete expense successfully', async () => {
|
||||
const createdExpense = await prisma.expense.create({
|
||||
data: {
|
||||
description: 'مصروف للحذف',
|
||||
category: 'أخرى',
|
||||
amount: 100.00,
|
||||
},
|
||||
});
|
||||
|
||||
await deleteExpense(createdExpense.id);
|
||||
|
||||
const deletedExpense = await prisma.expense.findUnique({
|
||||
where: { id: createdExpense.id },
|
||||
});
|
||||
|
||||
expect(deletedExpense).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpenseCategories', () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.expense.createMany({
|
||||
data: [
|
||||
{ description: 'مصروف 1', category: 'قطع غيار', amount: 100 },
|
||||
{ description: 'مصروف 2', category: 'أدوات', amount: 200 },
|
||||
{ description: 'مصروف 3', category: 'قطع غيار', amount: 150 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should get unique expense categories', async () => {
|
||||
const categories = await getExpenseCategories();
|
||||
|
||||
expect(categories).toHaveLength(2);
|
||||
expect(categories).toContain('قطع غيار');
|
||||
expect(categories).toContain('أدوات');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpensesByCategory', () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.expense.createMany({
|
||||
data: [
|
||||
{ description: 'مصروف 1', category: 'قطع غيار', amount: 100 },
|
||||
{ description: 'مصروف 2', category: 'أدوات', amount: 200 },
|
||||
{ description: 'مصروف 3', category: 'قطع غيار', amount: 150 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should group expenses by category', async () => {
|
||||
const result = await getExpensesByCategory();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
const spareParts = result.find(r => r.category === 'قطع غيار');
|
||||
expect(spareParts?.total).toBe(250);
|
||||
expect(spareParts?.count).toBe(2);
|
||||
|
||||
const tools = result.find(r => r.category === 'أدوات');
|
||||
expect(tools?.total).toBe(200);
|
||||
expect(tools?.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTotalExpenses', () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.expense.createMany({
|
||||
data: [
|
||||
{
|
||||
description: 'مصروف 1',
|
||||
category: 'قطع غيار',
|
||||
amount: 100,
|
||||
expenseDate: new Date('2024-01-15'),
|
||||
},
|
||||
{
|
||||
description: 'مصروف 2',
|
||||
category: 'أدوات',
|
||||
amount: 200,
|
||||
expenseDate: new Date('2024-01-10'),
|
||||
},
|
||||
{
|
||||
description: 'مصروف 3',
|
||||
category: 'إيجار',
|
||||
amount: 3000,
|
||||
expenseDate: new Date('2023-12-15'),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should calculate total expenses', async () => {
|
||||
const total = await getTotalExpenses();
|
||||
|
||||
expect(total).toBe(3300);
|
||||
});
|
||||
|
||||
it('should calculate total expenses for date range', async () => {
|
||||
const dateFrom = new Date('2024-01-01');
|
||||
const dateTo = new Date('2024-01-31');
|
||||
|
||||
const total = await getTotalExpenses(dateFrom, dateTo);
|
||||
|
||||
expect(total).toBe(300); // Only expenses from January 2024
|
||||
});
|
||||
});
|
||||
});
|
||||
458
app/lib/__tests__/financial-reporting.test.ts
Normal file
458
app/lib/__tests__/financial-reporting.test.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
getFinancialSummary,
|
||||
getMonthlyFinancialData,
|
||||
getIncomeByMaintenanceType,
|
||||
getExpenseBreakdown,
|
||||
getTopCustomersByRevenue,
|
||||
getFinancialTrends
|
||||
} from '../financial-reporting.server';
|
||||
import { prisma } from '../db.server';
|
||||
|
||||
describe('Financial Reporting', () => {
|
||||
beforeEach(async () => {
|
||||
// Clean up test data
|
||||
await prisma.income.deleteMany();
|
||||
await prisma.expense.deleteMany();
|
||||
await prisma.maintenanceVisit.deleteMany();
|
||||
await prisma.vehicle.deleteMany();
|
||||
await prisma.customer.deleteMany();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test data
|
||||
await prisma.income.deleteMany();
|
||||
await prisma.expense.deleteMany();
|
||||
await prisma.maintenanceVisit.deleteMany();
|
||||
await prisma.vehicle.deleteMany();
|
||||
await prisma.customer.deleteMany();
|
||||
});
|
||||
|
||||
describe('getFinancialSummary', () => {
|
||||
beforeEach(async () => {
|
||||
// Create test customer
|
||||
const customer = await prisma.customer.create({
|
||||
data: {
|
||||
name: 'عميل تجريبي',
|
||||
phone: '0501234567',
|
||||
},
|
||||
});
|
||||
|
||||
// Create test vehicle
|
||||
const vehicle = await prisma.vehicle.create({
|
||||
data: {
|
||||
plateNumber: 'ABC-123',
|
||||
bodyType: 'سيدان',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: customer.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test maintenance visit
|
||||
const visit = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
customerId: customer.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 200.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test income
|
||||
await prisma.income.create({
|
||||
data: {
|
||||
maintenanceVisitId: visit.id,
|
||||
amount: 200.00,
|
||||
incomeDate: new Date('2024-01-15'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create test expenses
|
||||
await prisma.expense.createMany({
|
||||
data: [
|
||||
{
|
||||
description: 'قطع غيار',
|
||||
category: 'قطع غيار',
|
||||
amount: 50.00,
|
||||
expenseDate: new Date('2024-01-10'),
|
||||
},
|
||||
{
|
||||
description: 'أدوات',
|
||||
category: 'أدوات',
|
||||
amount: 30.00,
|
||||
expenseDate: new Date('2024-01-12'),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should calculate financial summary correctly', async () => {
|
||||
const summary = await getFinancialSummary();
|
||||
|
||||
expect(summary.totalIncome).toBe(200.00);
|
||||
expect(summary.totalExpenses).toBe(80.00);
|
||||
expect(summary.netProfit).toBe(120.00);
|
||||
expect(summary.incomeCount).toBe(1);
|
||||
expect(summary.expenseCount).toBe(2);
|
||||
expect(summary.profitMargin).toBe(60.0); // (120/200) * 100
|
||||
});
|
||||
|
||||
it('should filter by date range', async () => {
|
||||
const dateFrom = new Date('2024-01-11');
|
||||
const dateTo = new Date('2024-01-20');
|
||||
|
||||
const summary = await getFinancialSummary(dateFrom, dateTo);
|
||||
|
||||
expect(summary.totalIncome).toBe(200.00);
|
||||
expect(summary.totalExpenses).toBe(30.00); // Only one expense in range
|
||||
expect(summary.netProfit).toBe(170.00);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIncomeByMaintenanceType', () => {
|
||||
beforeEach(async () => {
|
||||
// Create test data
|
||||
const customer = await prisma.customer.create({
|
||||
data: { name: 'عميل تجريبي', phone: '0501234567' },
|
||||
});
|
||||
|
||||
const vehicle = await prisma.vehicle.create({
|
||||
data: {
|
||||
plateNumber: 'ABC-123',
|
||||
bodyType: 'سيدان',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: customer.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create maintenance visits with different types
|
||||
const visit1 = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
customerId: customer.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 200.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const visit2 = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
customerId: customer.id,
|
||||
maintenanceType: 'فحص دوري',
|
||||
description: 'فحص دوري شامل',
|
||||
cost: 150.00,
|
||||
kilometers: 51000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const visit3 = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
customerId: customer.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت مرة أخرى',
|
||||
cost: 180.00,
|
||||
kilometers: 52000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
// Create corresponding income records
|
||||
await prisma.income.createMany({
|
||||
data: [
|
||||
{ maintenanceVisitId: visit1.id, amount: 200.00 },
|
||||
{ maintenanceVisitId: visit2.id, amount: 150.00 },
|
||||
{ maintenanceVisitId: visit3.id, amount: 180.00 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should group income by maintenance type', async () => {
|
||||
const result = await getIncomeByMaintenanceType();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
const oilChange = result.find(r => r.category === 'تغيير زيت');
|
||||
expect(oilChange?.amount).toBe(380.00);
|
||||
expect(oilChange?.count).toBe(2);
|
||||
expect(oilChange?.percentage).toBeCloseTo(71.7, 1); // 380/530 * 100
|
||||
|
||||
const inspection = result.find(r => r.category === 'فحص دوري');
|
||||
expect(inspection?.amount).toBe(150.00);
|
||||
expect(inspection?.count).toBe(1);
|
||||
expect(inspection?.percentage).toBeCloseTo(28.3, 1); // 150/530 * 100
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpenseBreakdown', () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.expense.createMany({
|
||||
data: [
|
||||
{ description: 'قطع غيار 1', category: 'قطع غيار', amount: 100 },
|
||||
{ description: 'قطع غيار 2', category: 'قطع غيار', amount: 150 },
|
||||
{ description: 'أدوات 1', category: 'أدوات', amount: 200 },
|
||||
{ description: 'إيجار', category: 'إيجار', amount: 3000 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should group expenses by category with percentages', async () => {
|
||||
const result = await getExpenseBreakdown();
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
const rent = result.find(r => r.category === 'إيجار');
|
||||
expect(rent?.amount).toBe(3000);
|
||||
expect(rent?.count).toBe(1);
|
||||
expect(rent?.percentage).toBeCloseTo(86.96, 1); // 3000/3450 * 100
|
||||
|
||||
const spareParts = result.find(r => r.category === 'قطع غيار');
|
||||
expect(spareParts?.amount).toBe(250);
|
||||
expect(spareParts?.count).toBe(2);
|
||||
expect(spareParts?.percentage).toBeCloseTo(7.25, 1); // 250/3450 * 100
|
||||
|
||||
const tools = result.find(r => r.category === 'أدوات');
|
||||
expect(tools?.amount).toBe(200);
|
||||
expect(tools?.count).toBe(1);
|
||||
expect(tools?.percentage).toBeCloseTo(5.80, 1); // 200/3450 * 100
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTopCustomersByRevenue', () => {
|
||||
beforeEach(async () => {
|
||||
// Create test customers
|
||||
const customer1 = await prisma.customer.create({
|
||||
data: { name: 'عميل أول', phone: '0501111111' },
|
||||
});
|
||||
|
||||
const customer2 = await prisma.customer.create({
|
||||
data: { name: 'عميل ثاني', phone: '0502222222' },
|
||||
});
|
||||
|
||||
// Create vehicles
|
||||
const vehicle1 = await prisma.vehicle.create({
|
||||
data: {
|
||||
plateNumber: 'ABC-111',
|
||||
bodyType: 'سيدان',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: customer1.id,
|
||||
},
|
||||
});
|
||||
|
||||
const vehicle2 = await prisma.vehicle.create({
|
||||
data: {
|
||||
plateNumber: 'ABC-222',
|
||||
bodyType: 'SUV',
|
||||
manufacturer: 'هيونداي',
|
||||
model: 'توسان',
|
||||
year: 2021,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: customer2.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create maintenance visits
|
||||
const visit1 = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle1.id,
|
||||
customerId: customer1.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت',
|
||||
cost: 300.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const visit2 = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle1.id,
|
||||
customerId: customer1.id,
|
||||
maintenanceType: 'فحص دوري',
|
||||
description: 'فحص دوري',
|
||||
cost: 200.00,
|
||||
kilometers: 51000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const visit3 = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle2.id,
|
||||
customerId: customer2.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت',
|
||||
cost: 150.00,
|
||||
kilometers: 30000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
// Create income records
|
||||
await prisma.income.createMany({
|
||||
data: [
|
||||
{ maintenanceVisitId: visit1.id, amount: 300.00 },
|
||||
{ maintenanceVisitId: visit2.id, amount: 200.00 },
|
||||
{ maintenanceVisitId: visit3.id, amount: 150.00 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return top customers by revenue', async () => {
|
||||
const result = await getTopCustomersByRevenue(10);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
// Should be sorted by revenue descending
|
||||
expect(result[0].customerName).toBe('عميل أول');
|
||||
expect(result[0].totalRevenue).toBe(500.00);
|
||||
expect(result[0].visitCount).toBe(2);
|
||||
|
||||
expect(result[1].customerName).toBe('عميل ثاني');
|
||||
expect(result[1].totalRevenue).toBe(150.00);
|
||||
expect(result[1].visitCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should limit results correctly', async () => {
|
||||
const result = await getTopCustomersByRevenue(1);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].customerName).toBe('عميل أول');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFinancialTrends', () => {
|
||||
beforeEach(async () => {
|
||||
// Create test customer and vehicle
|
||||
const customer = await prisma.customer.create({
|
||||
data: { name: 'عميل تجريبي', phone: '0501234567' },
|
||||
});
|
||||
|
||||
const vehicle = await prisma.vehicle.create({
|
||||
data: {
|
||||
plateNumber: 'ABC-123',
|
||||
bodyType: 'سيدان',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: customer.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create maintenance visits for different periods
|
||||
const currentVisit = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
customerId: customer.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت حالي',
|
||||
cost: 300.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const previousVisit = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
customerId: customer.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت سابق',
|
||||
cost: 200.00,
|
||||
kilometers: 45000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
// Create income for current period (Jan 2024)
|
||||
await prisma.income.create({
|
||||
data: {
|
||||
maintenanceVisitId: currentVisit.id,
|
||||
amount: 300.00,
|
||||
incomeDate: new Date('2024-01-15'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create income for previous period (Dec 2023)
|
||||
await prisma.income.create({
|
||||
data: {
|
||||
maintenanceVisitId: previousVisit.id,
|
||||
amount: 200.00,
|
||||
incomeDate: new Date('2023-12-15'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create expenses for current period
|
||||
await prisma.expense.create({
|
||||
data: {
|
||||
description: 'مصروف حالي',
|
||||
category: 'قطع غيار',
|
||||
amount: 100.00,
|
||||
expenseDate: new Date('2024-01-10'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create expenses for previous period
|
||||
await prisma.expense.create({
|
||||
data: {
|
||||
description: 'مصروف سابق',
|
||||
category: 'قطع غيار',
|
||||
amount: 80.00,
|
||||
expenseDate: new Date('2023-12-10'),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should calculate financial trends correctly', async () => {
|
||||
const dateFrom = new Date('2024-01-01');
|
||||
const dateTo = new Date('2024-01-31');
|
||||
|
||||
const result = await getFinancialTrends(dateFrom, dateTo);
|
||||
|
||||
expect(result.currentPeriod.totalIncome).toBe(300.00);
|
||||
expect(result.currentPeriod.totalExpenses).toBe(100.00);
|
||||
expect(result.currentPeriod.netProfit).toBe(200.00);
|
||||
|
||||
expect(result.previousPeriod.totalIncome).toBe(200.00);
|
||||
expect(result.previousPeriod.totalExpenses).toBe(80.00);
|
||||
expect(result.previousPeriod.netProfit).toBe(120.00);
|
||||
|
||||
// Income growth: (300-200)/200 * 100 = 50%
|
||||
expect(result.trends.incomeGrowth).toBeCloseTo(50.0, 1);
|
||||
|
||||
// Expense growth: (100-80)/80 * 100 = 25%
|
||||
expect(result.trends.expenseGrowth).toBeCloseTo(25.0, 1);
|
||||
|
||||
// Profit growth: (200-120)/120 * 100 = 66.67%
|
||||
expect(result.trends.profitGrowth).toBeCloseTo(66.67, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
201
app/lib/__tests__/form-validation.test.ts
Normal file
201
app/lib/__tests__/form-validation.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
validateUserData,
|
||||
validateCustomerData,
|
||||
validateVehicleData,
|
||||
validateMaintenanceVisitData,
|
||||
validateExpenseData
|
||||
} from '../form-validation';
|
||||
|
||||
describe('Form Validation', () => {
|
||||
describe('validateUserData', () => {
|
||||
it('should validate valid user data', () => {
|
||||
const validData = {
|
||||
name: 'أحمد محمد',
|
||||
username: 'ahmed123',
|
||||
email: 'ahmed@example.com',
|
||||
password: 'password123',
|
||||
authLevel: 3,
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
const result = validateUserData(validData);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should return errors for invalid user data', () => {
|
||||
const invalidData = {
|
||||
name: '',
|
||||
username: 'ab',
|
||||
email: 'invalid-email',
|
||||
password: '123',
|
||||
authLevel: 5,
|
||||
status: 'invalid',
|
||||
};
|
||||
|
||||
const result = validateUserData(invalidData);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.name).toBe('الاسم مطلوب');
|
||||
expect(result.errors.username).toBe('اسم المستخدم يجب أن يكون على الأقل 3 أحرف');
|
||||
expect(result.errors.email).toBe('البريد الإلكتروني غير صحيح');
|
||||
expect(result.errors.password).toBe('كلمة المرور يجب أن تكون على الأقل 6 أحرف');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCustomerData', () => {
|
||||
it('should validate valid customer data', () => {
|
||||
const validData = {
|
||||
name: 'محمد أحمد',
|
||||
phone: '+966501234567',
|
||||
email: 'mohammed@example.com',
|
||||
address: 'الرياض، المملكة العربية السعودية',
|
||||
};
|
||||
|
||||
const result = validateCustomerData(validData);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should validate customer with minimal data', () => {
|
||||
const validData = {
|
||||
name: 'سارة علي',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
};
|
||||
|
||||
const result = validateCustomerData(validData);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should return errors for invalid customer data', () => {
|
||||
const invalidData = {
|
||||
name: '',
|
||||
phone: 'invalid-phone',
|
||||
email: 'invalid-email',
|
||||
address: 'Valid address',
|
||||
};
|
||||
|
||||
const result = validateCustomerData(invalidData);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.name).toBe('اسم العميل مطلوب');
|
||||
expect(result.errors.phone).toBe('رقم الهاتف غير صحيح');
|
||||
expect(result.errors.email).toBe('البريد الإلكتروني غير صحيح');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateVehicleData', () => {
|
||||
it('should validate valid vehicle data', () => {
|
||||
const validData = {
|
||||
plateNumber: 'ABC-1234',
|
||||
bodyType: 'Sedan',
|
||||
manufacturer: 'Toyota',
|
||||
model: 'Camry',
|
||||
trim: 'LE',
|
||||
year: '2023',
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
cylinders: '4',
|
||||
engineDisplacement: '2.5',
|
||||
useType: 'personal',
|
||||
ownerId: '1',
|
||||
};
|
||||
|
||||
const result = validateVehicleData(validData);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should return errors for invalid vehicle data', () => {
|
||||
const invalidData = {
|
||||
plateNumber: '',
|
||||
bodyType: '',
|
||||
manufacturer: '',
|
||||
model: '',
|
||||
year: '1800',
|
||||
transmission: 'invalid',
|
||||
fuel: 'invalid',
|
||||
cylinders: '20',
|
||||
engineDisplacement: '50',
|
||||
useType: 'invalid',
|
||||
ownerId: '0',
|
||||
};
|
||||
|
||||
const result = validateVehicleData(invalidData);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.plateNumber).toBe('رقم اللوحة مطلوب');
|
||||
expect(result.errors.bodyType).toBe('نوع الهيكل مطلوب');
|
||||
expect(result.errors.manufacturer).toBe('الشركة المصنعة مطلوبة');
|
||||
expect(result.errors.model).toBe('الموديل مطلوب');
|
||||
expect(result.errors.ownerId).toBe('مالك المركبة مطلوب');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMaintenanceVisitData', () => {
|
||||
it('should validate valid maintenance visit data', () => {
|
||||
const validData = {
|
||||
vehicleId: '1',
|
||||
customerId: '1',
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك والفلتر',
|
||||
cost: '150.50',
|
||||
paymentStatus: 'paid',
|
||||
kilometers: '50000',
|
||||
nextVisitDelay: '3',
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisitData(validData);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should return errors for invalid maintenance visit data', () => {
|
||||
const invalidData = {
|
||||
vehicleId: '0',
|
||||
customerId: '0',
|
||||
maintenanceType: '',
|
||||
description: '',
|
||||
cost: '-10',
|
||||
paymentStatus: 'invalid',
|
||||
kilometers: '-100',
|
||||
nextVisitDelay: '5',
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisitData(invalidData);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.vehicleId).toBe('المركبة مطلوبة');
|
||||
expect(result.errors.customerId).toBe('العميل مطلوب');
|
||||
expect(result.errors.maintenanceType).toBe('نوع الصيانة مطلوب');
|
||||
expect(result.errors.description).toBe('وصف الصيانة مطلوب');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateExpenseData', () => {
|
||||
it('should validate valid expense data', () => {
|
||||
const validData = {
|
||||
description: 'شراء قطع غيار',
|
||||
category: 'قطع غيار',
|
||||
amount: '250.75',
|
||||
};
|
||||
|
||||
const result = validateExpenseData(validData);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should return errors for invalid expense data', () => {
|
||||
const invalidData = {
|
||||
description: '',
|
||||
category: '',
|
||||
amount: '0',
|
||||
};
|
||||
|
||||
const result = validateExpenseData(invalidData);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.description).toBe('وصف المصروف مطلوب');
|
||||
expect(result.errors.category).toBe('فئة المصروف مطلوبة');
|
||||
});
|
||||
});
|
||||
});
|
||||
434
app/lib/__tests__/maintenance-visit-management.test.ts
Normal file
434
app/lib/__tests__/maintenance-visit-management.test.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
createMaintenanceVisit,
|
||||
getMaintenanceVisits,
|
||||
getMaintenanceVisitById,
|
||||
updateMaintenanceVisit,
|
||||
deleteMaintenanceVisit,
|
||||
getVehicleMaintenanceHistory,
|
||||
getCustomerMaintenanceHistory
|
||||
} from '../maintenance-visit-management.server';
|
||||
import { prisma } from '../db.server';
|
||||
import type { Customer, Vehicle } from '@prisma/client';
|
||||
|
||||
describe('Maintenance Visit Management', () => {
|
||||
let testCustomer: Customer;
|
||||
let testVehicle: Vehicle;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test customer
|
||||
testCustomer = await prisma.customer.create({
|
||||
data: {
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
// Create test vehicle
|
||||
testVehicle = await prisma.vehicle.create({
|
||||
data: {
|
||||
plateNumber: 'أ ب ج 123',
|
||||
bodyType: 'سيدان',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: testCustomer.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test data
|
||||
await prisma.income.deleteMany();
|
||||
await prisma.maintenanceVisit.deleteMany();
|
||||
await prisma.vehicle.deleteMany();
|
||||
await prisma.customer.deleteMany();
|
||||
});
|
||||
|
||||
describe('createMaintenanceVisit', () => {
|
||||
it('should create a maintenance visit successfully', async () => {
|
||||
const visitData = {
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك والفلتر',
|
||||
cost: 150.00,
|
||||
paymentStatus: 'paid',
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const visit = await createMaintenanceVisit(visitData);
|
||||
|
||||
expect(visit).toBeDefined();
|
||||
expect(visit.vehicleId).toBe(testVehicle.id);
|
||||
expect(visit.customerId).toBe(testCustomer.id);
|
||||
expect(visit.maintenanceType).toBe('تغيير زيت');
|
||||
expect(visit.cost).toBe(150.00);
|
||||
expect(visit.nextVisitDelay).toBe(3);
|
||||
|
||||
// Check that vehicle was updated
|
||||
const updatedVehicle = await prisma.vehicle.findUnique({
|
||||
where: { id: testVehicle.id },
|
||||
});
|
||||
expect(updatedVehicle?.lastVisitDate).toBeDefined();
|
||||
expect(updatedVehicle?.suggestedNextVisitDate).toBeDefined();
|
||||
|
||||
// Check that income was created
|
||||
const income = await prisma.income.findFirst({
|
||||
where: { maintenanceVisitId: visit.id },
|
||||
});
|
||||
expect(income).toBeDefined();
|
||||
expect(income?.amount).toBe(150.00);
|
||||
});
|
||||
|
||||
it('should calculate next visit date correctly', async () => {
|
||||
const visitData = {
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'فحص دوري',
|
||||
description: 'فحص دوري شامل',
|
||||
cost: 200.00,
|
||||
kilometers: 45000,
|
||||
nextVisitDelay: 2, // 2 months
|
||||
};
|
||||
|
||||
await createMaintenanceVisit(visitData);
|
||||
|
||||
const updatedVehicle = await prisma.vehicle.findUnique({
|
||||
where: { id: testVehicle.id },
|
||||
});
|
||||
|
||||
expect(updatedVehicle?.suggestedNextVisitDate).toBeDefined();
|
||||
|
||||
// Check that the suggested date is approximately 2 months from now
|
||||
const now = new Date();
|
||||
const expectedDate = new Date();
|
||||
expectedDate.setMonth(expectedDate.getMonth() + 2);
|
||||
|
||||
const actualDate = updatedVehicle!.suggestedNextVisitDate!;
|
||||
const timeDiff = Math.abs(actualDate.getTime() - expectedDate.getTime());
|
||||
const daysDiff = timeDiff / (1000 * 60 * 60 * 24);
|
||||
|
||||
expect(daysDiff).toBeLessThan(2); // Allow 2 days difference
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMaintenanceVisits', () => {
|
||||
beforeEach(async () => {
|
||||
// Create test visits
|
||||
await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
});
|
||||
|
||||
await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'فحص فرامل',
|
||||
description: 'فحص وتنظيف الفرامل',
|
||||
cost: 100.00,
|
||||
kilometers: 52000,
|
||||
nextVisitDelay: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all maintenance visits', async () => {
|
||||
const result = await getMaintenanceVisits();
|
||||
|
||||
expect(result.visits).toHaveLength(2);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.totalPages).toBe(1);
|
||||
});
|
||||
|
||||
it('should search maintenance visits by maintenance type', async () => {
|
||||
const result = await getMaintenanceVisits('تغيير زيت');
|
||||
|
||||
expect(result.visits).toHaveLength(1);
|
||||
expect(result.visits[0].maintenanceType).toBe('تغيير زيت');
|
||||
});
|
||||
|
||||
it('should filter by vehicle ID', async () => {
|
||||
const result = await getMaintenanceVisits('', 1, 10, testVehicle.id);
|
||||
|
||||
expect(result.visits).toHaveLength(2);
|
||||
result.visits.forEach(visit => {
|
||||
expect(visit.vehicleId).toBe(testVehicle.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter by customer ID', async () => {
|
||||
const result = await getMaintenanceVisits('', 1, 10, undefined, testCustomer.id);
|
||||
|
||||
expect(result.visits).toHaveLength(2);
|
||||
result.visits.forEach(visit => {
|
||||
expect(visit.customerId).toBe(testCustomer.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('should paginate results', async () => {
|
||||
const result = await getMaintenanceVisits('', 1, 1);
|
||||
|
||||
expect(result.visits).toHaveLength(1);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.totalPages).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMaintenanceVisitById', () => {
|
||||
it('should get a maintenance visit by ID', async () => {
|
||||
const createdVisit = await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'تغيير إطارات',
|
||||
description: 'تغيير الإطارات الأمامية',
|
||||
cost: 800.00,
|
||||
kilometers: 55000,
|
||||
nextVisitDelay: 4,
|
||||
});
|
||||
|
||||
const visit = await getMaintenanceVisitById(createdVisit.id);
|
||||
|
||||
expect(visit).toBeDefined();
|
||||
expect(visit?.id).toBe(createdVisit.id);
|
||||
expect(visit?.vehicle).toBeDefined();
|
||||
expect(visit?.customer).toBeDefined();
|
||||
expect(visit?.income).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return null for non-existent visit', async () => {
|
||||
const visit = await getMaintenanceVisitById(99999);
|
||||
expect(visit).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMaintenanceVisit', () => {
|
||||
it('should update a maintenance visit successfully', async () => {
|
||||
const createdVisit = await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'فحص دوري',
|
||||
description: 'فحص دوري أولي',
|
||||
cost: 200.00,
|
||||
kilometers: 48000,
|
||||
nextVisitDelay: 3,
|
||||
});
|
||||
|
||||
const updateData = {
|
||||
maintenanceType: 'فحص شامل',
|
||||
description: 'فحص شامل محدث',
|
||||
cost: 250.00,
|
||||
paymentStatus: 'paid',
|
||||
kilometers: 48500,
|
||||
nextVisitDelay: 2,
|
||||
};
|
||||
|
||||
const updatedVisit = await updateMaintenanceVisit(createdVisit.id, updateData);
|
||||
|
||||
expect(updatedVisit.maintenanceType).toBe('فحص شامل');
|
||||
expect(updatedVisit.description).toBe('فحص شامل محدث');
|
||||
expect(updatedVisit.cost).toBe(250.00);
|
||||
expect(updatedVisit.paymentStatus).toBe('paid');
|
||||
expect(updatedVisit.kilometers).toBe(48500);
|
||||
expect(updatedVisit.nextVisitDelay).toBe(2);
|
||||
|
||||
// Check that income was updated
|
||||
const income = await prisma.income.findFirst({
|
||||
where: { maintenanceVisitId: createdVisit.id },
|
||||
});
|
||||
expect(income?.amount).toBe(250.00);
|
||||
});
|
||||
|
||||
it('should update vehicle dates when visit date changes', async () => {
|
||||
const createdVisit = await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'صيانة عامة',
|
||||
description: 'صيانة عامة',
|
||||
cost: 300.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
});
|
||||
|
||||
const newVisitDate = new Date();
|
||||
newVisitDate.setDate(newVisitDate.getDate() - 5); // 5 days ago
|
||||
|
||||
await updateMaintenanceVisit(createdVisit.id, {
|
||||
visitDate: newVisitDate,
|
||||
nextVisitDelay: 4,
|
||||
});
|
||||
|
||||
const updatedVehicle = await prisma.vehicle.findUnique({
|
||||
where: { id: testVehicle.id },
|
||||
});
|
||||
|
||||
expect(updatedVehicle?.lastVisitDate?.getTime()).toBe(newVisitDate.getTime());
|
||||
|
||||
// Check that suggested next visit date was recalculated
|
||||
const expectedNextDate = new Date(newVisitDate);
|
||||
expectedNextDate.setMonth(expectedNextDate.getMonth() + 4);
|
||||
|
||||
const actualNextDate = updatedVehicle!.suggestedNextVisitDate!;
|
||||
const timeDiff = Math.abs(actualNextDate.getTime() - expectedNextDate.getTime());
|
||||
const daysDiff = timeDiff / (1000 * 60 * 60 * 24);
|
||||
|
||||
expect(daysDiff).toBeLessThan(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMaintenanceVisit', () => {
|
||||
it('should delete a maintenance visit successfully', async () => {
|
||||
const createdVisit = await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'تنظيف مكيف',
|
||||
description: 'تنظيف وصيانة المكيف',
|
||||
cost: 120.00,
|
||||
kilometers: 47000,
|
||||
nextVisitDelay: 2,
|
||||
});
|
||||
|
||||
await deleteMaintenanceVisit(createdVisit.id);
|
||||
|
||||
// Check that visit was deleted
|
||||
const deletedVisit = await prisma.maintenanceVisit.findUnique({
|
||||
where: { id: createdVisit.id },
|
||||
});
|
||||
expect(deletedVisit).toBeNull();
|
||||
|
||||
// Check that income was deleted (cascade)
|
||||
const income = await prisma.income.findFirst({
|
||||
where: { maintenanceVisitId: createdVisit.id },
|
||||
});
|
||||
expect(income).toBeNull();
|
||||
|
||||
// Check that vehicle dates were cleared
|
||||
const updatedVehicle = await prisma.vehicle.findUnique({
|
||||
where: { id: testVehicle.id },
|
||||
});
|
||||
expect(updatedVehicle?.lastVisitDate).toBeNull();
|
||||
expect(updatedVehicle?.suggestedNextVisitDate).toBeNull();
|
||||
});
|
||||
|
||||
it('should update vehicle dates to most recent remaining visit after deletion', async () => {
|
||||
// Create two visits
|
||||
const firstVisit = await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'زيت قديم',
|
||||
description: 'تغيير زيت قديم',
|
||||
cost: 100.00,
|
||||
kilometers: 45000,
|
||||
nextVisitDelay: 3,
|
||||
visitDate: new Date('2023-01-01'),
|
||||
});
|
||||
|
||||
const secondVisit = await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'زيت جديد',
|
||||
description: 'تغيير زيت جديد',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 2,
|
||||
visitDate: new Date('2023-02-01'),
|
||||
});
|
||||
|
||||
// Delete the more recent visit
|
||||
await deleteMaintenanceVisit(secondVisit.id);
|
||||
|
||||
// Check that vehicle dates were updated to the remaining visit
|
||||
const updatedVehicle = await prisma.vehicle.findUnique({
|
||||
where: { id: testVehicle.id },
|
||||
});
|
||||
|
||||
expect(updatedVehicle?.lastVisitDate?.getTime()).toBe(new Date('2023-01-01').getTime());
|
||||
|
||||
// Check that suggested next visit date was recalculated based on first visit
|
||||
const expectedNextDate = new Date('2023-01-01');
|
||||
expectedNextDate.setMonth(expectedNextDate.getMonth() + 3);
|
||||
|
||||
const actualNextDate = updatedVehicle!.suggestedNextVisitDate!;
|
||||
expect(actualNextDate.getTime()).toBe(expectedNextDate.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVehicleMaintenanceHistory', () => {
|
||||
it('should get maintenance history for a vehicle', async () => {
|
||||
await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'زيت أول',
|
||||
description: 'تغيير زيت أول',
|
||||
cost: 100.00,
|
||||
kilometers: 45000,
|
||||
nextVisitDelay: 3,
|
||||
});
|
||||
|
||||
await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'زيت ثاني',
|
||||
description: 'تغيير زيت ثاني',
|
||||
cost: 120.00,
|
||||
kilometers: 48000,
|
||||
nextVisitDelay: 3,
|
||||
});
|
||||
|
||||
const history = await getVehicleMaintenanceHistory(testVehicle.id);
|
||||
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history[0].vehicleId).toBe(testVehicle.id);
|
||||
expect(history[1].vehicleId).toBe(testVehicle.id);
|
||||
|
||||
// Should be ordered by visit date descending
|
||||
expect(new Date(history[0].visitDate).getTime()).toBeGreaterThanOrEqual(
|
||||
new Date(history[1].visitDate).getTime()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCustomerMaintenanceHistory', () => {
|
||||
it('should get maintenance history for a customer', async () => {
|
||||
await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'صيانة أولى',
|
||||
description: 'صيانة أولى',
|
||||
cost: 200.00,
|
||||
kilometers: 45000,
|
||||
nextVisitDelay: 3,
|
||||
});
|
||||
|
||||
await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'صيانة ثانية',
|
||||
description: 'صيانة ثانية',
|
||||
cost: 250.00,
|
||||
kilometers: 48000,
|
||||
nextVisitDelay: 2,
|
||||
});
|
||||
|
||||
const history = await getCustomerMaintenanceHistory(testCustomer.id);
|
||||
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history[0].customerId).toBe(testCustomer.id);
|
||||
expect(history[1].customerId).toBe(testCustomer.id);
|
||||
|
||||
// Should be ordered by visit date descending
|
||||
expect(new Date(history[0].visitDate).getTime()).toBeGreaterThanOrEqual(
|
||||
new Date(history[1].visitDate).getTime()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
425
app/lib/__tests__/maintenance-visit-validation.test.ts
Normal file
425
app/lib/__tests__/maintenance-visit-validation.test.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateMaintenanceVisit } from '../validation';
|
||||
|
||||
describe('Maintenance Visit Validation', () => {
|
||||
describe('validateMaintenanceVisit', () => {
|
||||
it('should validate a complete maintenance visit successfully', () => {
|
||||
const validData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك والفلتر',
|
||||
cost: 150.50,
|
||||
paymentStatus: 'paid',
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(validData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip validation for missing vehicleId (partial validation)', () => {
|
||||
const partialData = {
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject invalid vehicleId', () => {
|
||||
const invalidData = {
|
||||
vehicleId: 0,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.vehicleId).toBe('المركبة مطلوبة');
|
||||
});
|
||||
|
||||
it('should skip validation for missing customerId (partial validation)', () => {
|
||||
const partialData = {
|
||||
vehicleId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject invalid customerId', () => {
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: -1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.customerId).toBe('العميل مطلوب');
|
||||
});
|
||||
|
||||
it('should skip validation for missing maintenanceType (partial validation)', () => {
|
||||
const partialData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject empty maintenanceType', () => {
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: ' ',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.maintenanceType).toBe('نوع الصيانة مطلوب');
|
||||
});
|
||||
|
||||
it('should skip validation for missing description (partial validation)', () => {
|
||||
const partialData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject empty description', () => {
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: '',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.description).toBe('وصف الصيانة مطلوب');
|
||||
});
|
||||
|
||||
it('should reject description that is too long', () => {
|
||||
const longDescription = 'أ'.repeat(501); // 501 characters
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: longDescription,
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.description).toBe('الوصف يجب أن يكون أقل من 500 حرف');
|
||||
});
|
||||
|
||||
it('should skip validation for missing cost (partial validation)', () => {
|
||||
const partialData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject negative cost', () => {
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: -10.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.cost).toBe('التكلفة يجب أن تكون بين 0 و 999999.99');
|
||||
});
|
||||
|
||||
it('should reject cost that is too high', () => {
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 1000000.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.cost).toBe('التكلفة يجب أن تكون بين 0 و 999999.99');
|
||||
});
|
||||
|
||||
it('should validate payment status', () => {
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
paymentStatus: 'invalid_status',
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.paymentStatus).toBe('حالة الدفع غير صحيحة');
|
||||
});
|
||||
|
||||
it('should accept valid payment statuses', () => {
|
||||
const validStatuses = ['pending', 'paid', 'partial', 'cancelled'];
|
||||
|
||||
validStatuses.forEach(status => {
|
||||
const validData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
paymentStatus: status,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(validData);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip validation for missing kilometers (partial validation)', () => {
|
||||
const partialData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject negative kilometers', () => {
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: -1000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.kilometers).toBe('عدد الكيلومترات يجب أن يكون رقم موجب');
|
||||
});
|
||||
|
||||
it('should accept zero kilometers', () => {
|
||||
const validData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 0,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(validData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip validation for missing nextVisitDelay (partial validation)', () => {
|
||||
const partialData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept valid nextVisitDelay values', () => {
|
||||
const validDelays = [1, 2, 3, 4];
|
||||
|
||||
validDelays.forEach(delay => {
|
||||
const validData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: delay,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(validData);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid nextVisitDelay values', () => {
|
||||
const invalidDelays = [0, 5, 6, -1];
|
||||
|
||||
invalidDelays.forEach(delay => {
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: delay,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.nextVisitDelay).toBe('فترة الزيارة التالية يجب أن تكون 1، 2، 3، أو 4 أشهر');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle partial validation for updates', () => {
|
||||
const partialData = {
|
||||
maintenanceType: 'فحص دوري',
|
||||
cost: 200.00,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should validate partial data with errors', () => {
|
||||
const partialData = {
|
||||
maintenanceType: '',
|
||||
cost: -50.00,
|
||||
paymentStatus: 'invalid',
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.maintenanceType).toBe('نوع الصيانة مطلوب');
|
||||
expect(result.errors.cost).toBe('التكلفة يجب أن تكون بين 0 و 999999.99');
|
||||
expect(result.errors.paymentStatus).toBe('حالة الدفع غير صحيحة');
|
||||
});
|
||||
|
||||
it('should accept zero cost', () => {
|
||||
const validData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'فحص مجاني',
|
||||
description: 'فحص مجاني للعميل',
|
||||
cost: 0,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(validData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept decimal cost values', () => {
|
||||
const validData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.75,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(validData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
107
app/lib/__tests__/route-protection-integration.test.ts
Normal file
107
app/lib/__tests__/route-protection-integration.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { checkPermission, createUnauthorizedResponse } from "../auth-middleware.server";
|
||||
import { AUTH_LEVELS, USER_STATUS } from "~/types/auth";
|
||||
import type { SafeUser } from "~/types/auth";
|
||||
|
||||
// Mock user data for testing permissions
|
||||
const mockSuperAdmin: SafeUser = {
|
||||
id: 1,
|
||||
name: "Super Admin",
|
||||
username: "superadmin",
|
||||
email: "super@example.com",
|
||||
status: USER_STATUS.ACTIVE,
|
||||
authLevel: AUTH_LEVELS.SUPERADMIN,
|
||||
createdDate: new Date(),
|
||||
editDate: new Date(),
|
||||
};
|
||||
|
||||
const mockAdmin: SafeUser = {
|
||||
id: 2,
|
||||
name: "Admin User",
|
||||
username: "admin",
|
||||
email: "admin@example.com",
|
||||
status: USER_STATUS.ACTIVE,
|
||||
authLevel: AUTH_LEVELS.ADMIN,
|
||||
createdDate: new Date(),
|
||||
editDate: new Date(),
|
||||
};
|
||||
|
||||
const mockUser: SafeUser = {
|
||||
id: 3,
|
||||
name: "Regular User",
|
||||
username: "user",
|
||||
email: "user@example.com",
|
||||
status: USER_STATUS.ACTIVE,
|
||||
authLevel: AUTH_LEVELS.USER,
|
||||
createdDate: new Date(),
|
||||
editDate: new Date(),
|
||||
};
|
||||
|
||||
describe("Route Protection Integration Tests", () => {
|
||||
describe("checkPermission", () => {
|
||||
it("should correctly check view_all_users permission", () => {
|
||||
expect(checkPermission(mockSuperAdmin, "view_all_users")).toBe(true);
|
||||
expect(checkPermission(mockAdmin, "view_all_users")).toBe(false);
|
||||
expect(checkPermission(mockUser, "view_all_users")).toBe(false);
|
||||
});
|
||||
|
||||
it("should correctly check create_users permission", () => {
|
||||
expect(checkPermission(mockSuperAdmin, "create_users")).toBe(true);
|
||||
expect(checkPermission(mockAdmin, "create_users")).toBe(true);
|
||||
expect(checkPermission(mockUser, "create_users")).toBe(false);
|
||||
});
|
||||
|
||||
it("should correctly check manage_finances permission", () => {
|
||||
expect(checkPermission(mockSuperAdmin, "manage_finances")).toBe(true);
|
||||
expect(checkPermission(mockAdmin, "manage_finances")).toBe(true);
|
||||
expect(checkPermission(mockUser, "manage_finances")).toBe(false);
|
||||
});
|
||||
|
||||
it("should correctly check view_reports permission", () => {
|
||||
expect(checkPermission(mockSuperAdmin, "view_reports")).toBe(true);
|
||||
expect(checkPermission(mockAdmin, "view_reports")).toBe(true);
|
||||
expect(checkPermission(mockUser, "view_reports")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for unknown permission", () => {
|
||||
expect(checkPermission(mockUser, "unknown_permission" as any)).toBe(false);
|
||||
expect(checkPermission(mockAdmin, "unknown_permission" as any)).toBe(false);
|
||||
expect(checkPermission(mockSuperAdmin, "unknown_permission" as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUnauthorizedResponse", () => {
|
||||
it("should create response with default message", () => {
|
||||
const response = createUnauthorizedResponse();
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
|
||||
});
|
||||
|
||||
it("should create response with custom message", () => {
|
||||
const customMessage = "Custom error message";
|
||||
const response = createUnauthorizedResponse(customMessage);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Auth Level Hierarchy", () => {
|
||||
it("should have correct auth level values", () => {
|
||||
expect(AUTH_LEVELS.SUPERADMIN).toBe(1);
|
||||
expect(AUTH_LEVELS.ADMIN).toBe(2);
|
||||
expect(AUTH_LEVELS.USER).toBe(3);
|
||||
});
|
||||
|
||||
it("should enforce correct hierarchy (lower number = higher privilege)", () => {
|
||||
expect(AUTH_LEVELS.SUPERADMIN < AUTH_LEVELS.ADMIN).toBe(true);
|
||||
expect(AUTH_LEVELS.ADMIN < AUTH_LEVELS.USER).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Status", () => {
|
||||
it("should have correct status values", () => {
|
||||
expect(USER_STATUS.ACTIVE).toBe("active");
|
||||
expect(USER_STATUS.INACTIVE).toBe("inactive");
|
||||
});
|
||||
});
|
||||
});
|
||||
299
app/lib/__tests__/user-management.test.ts
Normal file
299
app/lib/__tests__/user-management.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { prisma } from '../db.server';
|
||||
import { getUsers, createUser, updateUser, deleteUser, toggleUserStatus } from '../user-management.server';
|
||||
import { hashPassword } from '../auth.server';
|
||||
import { AUTH_LEVELS, USER_STATUS } from '~/types/auth';
|
||||
|
||||
describe('User Management', () => {
|
||||
beforeEach(async () => {
|
||||
// Clean up test data
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
email: {
|
||||
contains: 'test'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure the main superadmin exists for login functionality
|
||||
const existingSuperadmin = await prisma.user.findUnique({
|
||||
where: { username: 'superadmin' }
|
||||
});
|
||||
|
||||
if (!existingSuperadmin) {
|
||||
const hashedPassword = await hashPassword('admin123');
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
name: 'Super Administrator',
|
||||
username: 'superadmin',
|
||||
email: 'admin@carmaintenance.com',
|
||||
password: hashedPassword,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
authLevel: AUTH_LEVELS.SUPERADMIN,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test data but preserve the main superadmin
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
email: {
|
||||
contains: 'test'
|
||||
}
|
||||
},
|
||||
{
|
||||
username: {
|
||||
not: 'superadmin'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUsers', () => {
|
||||
it('should return users with role-based filtering', async () => {
|
||||
// Create test users with properly hashed passwords
|
||||
const hashedPassword = await hashPassword('testpassword123');
|
||||
|
||||
const superadmin = await prisma.user.create({
|
||||
data: {
|
||||
name: 'Test Superadmin',
|
||||
username: 'testsuperadmin',
|
||||
email: 'testsuperadmin@example.com',
|
||||
password: hashedPassword,
|
||||
authLevel: AUTH_LEVELS.SUPERADMIN,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
}
|
||||
});
|
||||
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
name: 'Test Admin',
|
||||
username: 'testadmin',
|
||||
email: 'testadmin@example.com',
|
||||
password: hashedPassword,
|
||||
authLevel: AUTH_LEVELS.ADMIN,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
}
|
||||
});
|
||||
|
||||
// Superadmin should see all users
|
||||
const superadminResult = await getUsers(AUTH_LEVELS.SUPERADMIN);
|
||||
expect(superadminResult.users.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Admin should not see superadmin users
|
||||
const adminResult = await getUsers(AUTH_LEVELS.ADMIN);
|
||||
const adminVisibleUsers = adminResult.users.filter(u => u.authLevel === AUTH_LEVELS.SUPERADMIN);
|
||||
expect(adminVisibleUsers.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should support search functionality', async () => {
|
||||
const hashedPassword = await hashPassword('testpassword123');
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
name: 'John Test User',
|
||||
username: 'johntestuser',
|
||||
email: 'johntest@example.com',
|
||||
password: hashedPassword,
|
||||
authLevel: AUTH_LEVELS.USER,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
}
|
||||
});
|
||||
|
||||
const result = await getUsers(AUTH_LEVELS.SUPERADMIN, 'John');
|
||||
expect(result.users.some(u => u.name.includes('John'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUser', () => {
|
||||
it('should create a new user successfully', async () => {
|
||||
const userData = {
|
||||
name: 'New Test User',
|
||||
username: 'newtestuser',
|
||||
email: 'newtest@example.com',
|
||||
password: 'password123',
|
||||
authLevel: AUTH_LEVELS.USER,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
};
|
||||
|
||||
const result = await createUser(userData, AUTH_LEVELS.SUPERADMIN);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user).toBeDefined();
|
||||
expect(result.user?.name).toBe(userData.name);
|
||||
expect(result.user?.username).toBe(userData.username);
|
||||
expect(result.user?.email).toBe(userData.email);
|
||||
});
|
||||
|
||||
it('should prevent admin from creating superadmin', async () => {
|
||||
const userData = {
|
||||
name: 'Test Superadmin',
|
||||
username: 'testsuperadmin2',
|
||||
email: 'testsuperadmin2@example.com',
|
||||
password: 'password123',
|
||||
authLevel: AUTH_LEVELS.SUPERADMIN,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
};
|
||||
|
||||
const result = await createUser(userData, AUTH_LEVELS.ADMIN);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('لا يمكن للمدير إنشاء حساب مدير عام');
|
||||
});
|
||||
|
||||
it('should prevent duplicate username', async () => {
|
||||
const userData1 = {
|
||||
name: 'Test User 1',
|
||||
username: 'duplicatetest',
|
||||
email: 'test1@example.com',
|
||||
password: 'password123',
|
||||
authLevel: AUTH_LEVELS.USER,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
};
|
||||
|
||||
const userData2 = {
|
||||
name: 'Test User 2',
|
||||
username: 'duplicatetest', // Same username
|
||||
email: 'test2@example.com',
|
||||
password: 'password123',
|
||||
authLevel: AUTH_LEVELS.USER,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
};
|
||||
|
||||
await createUser(userData1, AUTH_LEVELS.SUPERADMIN);
|
||||
const result = await createUser(userData2, AUTH_LEVELS.SUPERADMIN);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('اسم المستخدم موجود بالفعل');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUser', () => {
|
||||
it('should update user successfully', async () => {
|
||||
const hashedPassword = await hashPassword('testpassword123');
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: 'Original Name',
|
||||
username: 'originaluser' + Date.now(),
|
||||
email: 'original' + Date.now() + '@example.com',
|
||||
password: hashedPassword,
|
||||
authLevel: AUTH_LEVELS.USER,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
}
|
||||
});
|
||||
|
||||
const updateData = {
|
||||
name: 'Updated Name',
|
||||
email: 'updated' + Date.now() + '@example.com',
|
||||
};
|
||||
|
||||
const result = await updateUser(user.id, updateData, AUTH_LEVELS.SUPERADMIN);
|
||||
|
||||
if (!result.success) {
|
||||
console.log('Update failed:', result.error);
|
||||
}
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.name).toBe('Updated Name');
|
||||
expect(result.user?.email).toBe(updateData.email);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleUserStatus', () => {
|
||||
it('should toggle user status', async () => {
|
||||
const hashedPassword = await hashPassword('testpassword123');
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: 'Test User',
|
||||
username: 'teststatususer' + Date.now(),
|
||||
email: 'teststatus' + Date.now() + '@example.com',
|
||||
password: hashedPassword,
|
||||
authLevel: AUTH_LEVELS.USER,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
}
|
||||
});
|
||||
|
||||
const result = await toggleUserStatus(user.id, AUTH_LEVELS.SUPERADMIN);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.status).toBe(USER_STATUS.INACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUser', () => {
|
||||
it('should delete user successfully', async () => {
|
||||
const hashedPassword = await hashPassword('testpassword123');
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: 'Delete Test User',
|
||||
username: 'deletetestuser' + Date.now(),
|
||||
email: 'deletetest' + Date.now() + '@example.com',
|
||||
password: hashedPassword,
|
||||
authLevel: AUTH_LEVELS.USER,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
}
|
||||
});
|
||||
|
||||
const result = await deleteUser(user.id, AUTH_LEVELS.SUPERADMIN);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify user is deleted
|
||||
const deletedUser = await prisma.user.findUnique({
|
||||
where: { id: user.id }
|
||||
});
|
||||
expect(deletedUser).toBeNull();
|
||||
});
|
||||
|
||||
it('should prevent deletion of last superadmin', async () => {
|
||||
// Delete any test superadmins but keep the main superadmin
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ authLevel: AUTH_LEVELS.SUPERADMIN },
|
||||
{ username: { not: 'superadmin' } }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Try to delete the main superadmin (should fail as it's the last one)
|
||||
const mainSuperadmin = await prisma.user.findUnique({
|
||||
where: { username: 'superadmin' }
|
||||
});
|
||||
|
||||
if (mainSuperadmin) {
|
||||
const result = await deleteUser(mainSuperadmin.id, AUTH_LEVELS.SUPERADMIN);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('لا يمكن حذف آخر مدير عام في النظام');
|
||||
} else {
|
||||
// If main superadmin doesn't exist, create one and test
|
||||
const hashedPassword = await hashPassword('admin123');
|
||||
const superadmin = await prisma.user.create({
|
||||
data: {
|
||||
name: 'Last Superadmin',
|
||||
username: 'lastsuperadmin' + Date.now(),
|
||||
email: 'lastsuperadmin' + Date.now() + '@example.com',
|
||||
password: hashedPassword,
|
||||
authLevel: AUTH_LEVELS.SUPERADMIN,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
}
|
||||
});
|
||||
|
||||
const result = await deleteUser(superadmin.id, AUTH_LEVELS.SUPERADMIN);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('لا يمكن حذف آخر مدير عام في النظام');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
222
app/lib/__tests__/validation-utils.test.ts
Normal file
222
app/lib/__tests__/validation-utils.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
validateField,
|
||||
validateFields,
|
||||
validateEmail,
|
||||
validatePhone,
|
||||
validatePassword,
|
||||
validateUsername,
|
||||
validatePlateNumber,
|
||||
validateYear,
|
||||
validateCurrency,
|
||||
sanitizeString,
|
||||
sanitizeNumber,
|
||||
sanitizeInteger,
|
||||
sanitizeFormData,
|
||||
PATTERNS
|
||||
} from '../validation-utils';
|
||||
|
||||
describe('Validation Utils', () => {
|
||||
describe('validateField', () => {
|
||||
it('should validate required fields', () => {
|
||||
const result1 = validateField('', { required: true });
|
||||
expect(result1.isValid).toBe(false);
|
||||
expect(result1.error).toBe('هذا الحقل مطلوب');
|
||||
|
||||
const result2 = validateField('value', { required: true });
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate email fields', () => {
|
||||
const result1 = validateField('invalid-email', { email: true });
|
||||
expect(result1.isValid).toBe(false);
|
||||
expect(result1.error).toBe('البريد الإلكتروني غير صحيح');
|
||||
|
||||
const result2 = validateField('test@example.com', { email: true });
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate phone fields', () => {
|
||||
const result1 = validateField('invalid-phone', { phone: true });
|
||||
expect(result1.isValid).toBe(false);
|
||||
expect(result1.error).toBe('رقم الهاتف غير صحيح');
|
||||
|
||||
const result2 = validateField('+966501234567', { phone: true });
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate length constraints', () => {
|
||||
const result1 = validateField('ab', { minLength: 3 });
|
||||
expect(result1.isValid).toBe(false);
|
||||
expect(result1.error).toBe('يجب أن يكون على الأقل 3 أحرف');
|
||||
|
||||
const result2 = validateField('a'.repeat(101), { maxLength: 100 });
|
||||
expect(result2.isValid).toBe(false);
|
||||
expect(result2.error).toBe('يجب أن يكون أقل من 100 حرف');
|
||||
});
|
||||
|
||||
it('should validate numeric constraints', () => {
|
||||
const result1 = validateField(5, { min: 10 });
|
||||
expect(result1.isValid).toBe(false);
|
||||
expect(result1.error).toBe('يجب أن يكون على الأقل 10');
|
||||
|
||||
const result2 = validateField(15, { max: 10 });
|
||||
expect(result2.isValid).toBe(false);
|
||||
expect(result2.error).toBe('يجب أن يكون أقل من 10');
|
||||
});
|
||||
|
||||
it('should validate custom rules', () => {
|
||||
const customRule = (value: any) => {
|
||||
return value === 'forbidden' ? 'هذه القيمة غير مسموحة' : null;
|
||||
};
|
||||
|
||||
const result1 = validateField('forbidden', { custom: customRule });
|
||||
expect(result1.isValid).toBe(false);
|
||||
expect(result1.error).toBe('هذه القيمة غير مسموحة');
|
||||
|
||||
const result2 = validateField('allowed', { custom: customRule });
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFields', () => {
|
||||
it('should validate multiple fields', () => {
|
||||
const data = {
|
||||
name: '',
|
||||
email: 'invalid-email',
|
||||
age: 5,
|
||||
};
|
||||
|
||||
const rules = {
|
||||
name: { required: true },
|
||||
email: { email: true },
|
||||
age: { min: 18 },
|
||||
};
|
||||
|
||||
const result = validateFields(data, rules);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.name).toBe('هذا الحقل مطلوب');
|
||||
expect(result.errors.email).toBe('البريد الإلكتروني غير صحيح');
|
||||
expect(result.errors.age).toBe('يجب أن يكون على الأقل 18');
|
||||
});
|
||||
});
|
||||
|
||||
describe('specific validation functions', () => {
|
||||
it('should validate email', () => {
|
||||
const result1 = validateEmail('');
|
||||
expect(result1.isValid).toBe(false);
|
||||
|
||||
const result2 = validateEmail('test@example.com');
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate phone', () => {
|
||||
const result1 = validatePhone('invalid');
|
||||
expect(result1.isValid).toBe(false);
|
||||
|
||||
const result2 = validatePhone('+966501234567');
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate password', () => {
|
||||
const result1 = validatePassword('123');
|
||||
expect(result1.isValid).toBe(false);
|
||||
|
||||
const result2 = validatePassword('password123');
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate username', () => {
|
||||
const result1 = validateUsername('ab');
|
||||
expect(result1.isValid).toBe(false);
|
||||
|
||||
const result2 = validateUsername('user123');
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate plate number', () => {
|
||||
const result1 = validatePlateNumber('');
|
||||
expect(result1.isValid).toBe(false);
|
||||
|
||||
const result2 = validatePlateNumber('ABC-1234');
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate year', () => {
|
||||
const result1 = validateYear(1800);
|
||||
expect(result1.isValid).toBe(false);
|
||||
|
||||
const result2 = validateYear(2023);
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate currency', () => {
|
||||
const result1 = validateCurrency(-10);
|
||||
expect(result1.isValid).toBe(false);
|
||||
|
||||
const result2 = validateCurrency(100.50);
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitization functions', () => {
|
||||
it('should sanitize strings', () => {
|
||||
expect(sanitizeString(' hello world ')).toBe('hello world');
|
||||
expect(sanitizeString('\t\n test \n\t')).toBe('test');
|
||||
});
|
||||
|
||||
it('should sanitize numbers', () => {
|
||||
expect(sanitizeNumber('123.45')).toBe(123.45);
|
||||
expect(sanitizeNumber('invalid')).toBe(null);
|
||||
expect(sanitizeNumber(456)).toBe(456);
|
||||
});
|
||||
|
||||
it('should sanitize integers', () => {
|
||||
expect(sanitizeInteger('123')).toBe(123);
|
||||
expect(sanitizeInteger('123.45')).toBe(123);
|
||||
expect(sanitizeInteger('invalid')).toBe(null);
|
||||
});
|
||||
|
||||
it('should sanitize form data', () => {
|
||||
const data = {
|
||||
name: ' John Doe ',
|
||||
age: 25,
|
||||
description: '\t\n Some text \n\t',
|
||||
};
|
||||
|
||||
const sanitized = sanitizeFormData(data);
|
||||
expect(sanitized.name).toBe('John Doe');
|
||||
expect(sanitized.age).toBe(25);
|
||||
expect(sanitized.description).toBe('Some text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('patterns', () => {
|
||||
it('should match email pattern', () => {
|
||||
expect(PATTERNS.email.test('test@example.com')).toBe(true);
|
||||
expect(PATTERNS.email.test('invalid-email')).toBe(false);
|
||||
});
|
||||
|
||||
it('should match phone pattern', () => {
|
||||
expect(PATTERNS.phone.test('+966501234567')).toBe(true);
|
||||
expect(PATTERNS.phone.test('0501234567')).toBe(true);
|
||||
expect(PATTERNS.phone.test('invalid-phone')).toBe(false);
|
||||
});
|
||||
|
||||
it('should match username pattern', () => {
|
||||
expect(PATTERNS.username.test('user123')).toBe(true);
|
||||
expect(PATTERNS.username.test('user_name')).toBe(true);
|
||||
expect(PATTERNS.username.test('user-name')).toBe(false);
|
||||
expect(PATTERNS.username.test('user name')).toBe(false);
|
||||
});
|
||||
|
||||
it('should match numeric patterns', () => {
|
||||
expect(PATTERNS.numeric.test('12345')).toBe(true);
|
||||
expect(PATTERNS.numeric.test('123abc')).toBe(false);
|
||||
|
||||
expect(PATTERNS.decimal.test('123.45')).toBe(true);
|
||||
expect(PATTERNS.decimal.test('123')).toBe(true);
|
||||
expect(PATTERNS.decimal.test('123.45.67')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
443
app/lib/__tests__/vehicle-management.test.ts
Normal file
443
app/lib/__tests__/vehicle-management.test.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
createVehicle,
|
||||
updateVehicle,
|
||||
deleteVehicle,
|
||||
getVehicles,
|
||||
getVehicleById,
|
||||
getVehiclesForSelect,
|
||||
searchVehicles,
|
||||
getVehicleStats
|
||||
} from '../vehicle-management.server';
|
||||
import { prisma } from '../db.server';
|
||||
|
||||
// Mock Prisma
|
||||
vi.mock('../db.server', () => ({
|
||||
prisma: {
|
||||
vehicle: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
customer: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Vehicle Management', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('createVehicle', () => {
|
||||
const validVehicleData = {
|
||||
plateNumber: 'ABC-123',
|
||||
bodyType: 'سيدان',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: 1,
|
||||
};
|
||||
|
||||
it('should create a vehicle successfully', async () => {
|
||||
const mockVehicle = { id: 1, ...validVehicleData };
|
||||
const mockCustomer = { id: 1, name: 'أحمد محمد' };
|
||||
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
|
||||
(prisma.customer.findUnique as any).mockResolvedValue(mockCustomer);
|
||||
(prisma.vehicle.create as any).mockResolvedValue(mockVehicle);
|
||||
|
||||
const result = await createVehicle(validVehicleData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.vehicle).toEqual(mockVehicle);
|
||||
expect(prisma.vehicle.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
plateNumber: 'ABC-123',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
ownerId: 1,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if plate number already exists', async () => {
|
||||
const existingVehicle = { id: 2, plateNumber: 'ABC-123' };
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(existingVehicle);
|
||||
|
||||
const result = await createVehicle(validVehicleData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('رقم اللوحة موجود بالفعل');
|
||||
expect(prisma.vehicle.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if owner does not exist', async () => {
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
|
||||
(prisma.customer.findUnique as any).mockResolvedValue(null);
|
||||
|
||||
const result = await createVehicle(validVehicleData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('المالك غير موجود');
|
||||
expect(prisma.vehicle.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
|
||||
(prisma.customer.findUnique as any).mockResolvedValue({ id: 1 });
|
||||
(prisma.vehicle.create as any).mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await createVehicle(validVehicleData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('حدث خطأ أثناء إنشاء المركبة');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateVehicle', () => {
|
||||
const updateData = {
|
||||
plateNumber: 'XYZ-789',
|
||||
manufacturer: 'هوندا',
|
||||
model: 'أكورد',
|
||||
};
|
||||
|
||||
it('should update a vehicle successfully', async () => {
|
||||
const existingVehicle = { id: 1, plateNumber: 'ABC-123', ownerId: 1 };
|
||||
const updatedVehicle = { ...existingVehicle, ...updateData };
|
||||
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(existingVehicle);
|
||||
(prisma.vehicle.findFirst as any).mockResolvedValue(null);
|
||||
(prisma.vehicle.update as any).mockResolvedValue(updatedVehicle);
|
||||
|
||||
const result = await updateVehicle(1, updateData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.vehicle).toEqual(updatedVehicle);
|
||||
expect(prisma.vehicle.update).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: expect.objectContaining({
|
||||
plateNumber: 'XYZ-789',
|
||||
manufacturer: 'هوندا',
|
||||
model: 'أكورد',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if vehicle does not exist', async () => {
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
|
||||
|
||||
const result = await updateVehicle(999, updateData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('المركبة غير موجودة');
|
||||
expect(prisma.vehicle.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if new plate number conflicts', async () => {
|
||||
const existingVehicle = { id: 1, plateNumber: 'ABC-123' };
|
||||
const conflictVehicle = { id: 2, plateNumber: 'XYZ-789' };
|
||||
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(existingVehicle);
|
||||
(prisma.vehicle.findFirst as any).mockResolvedValue(conflictVehicle);
|
||||
|
||||
const result = await updateVehicle(1, updateData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('رقم اللوحة موجود بالفعل');
|
||||
expect(prisma.vehicle.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteVehicle', () => {
|
||||
it('should delete a vehicle successfully', async () => {
|
||||
const existingVehicle = {
|
||||
id: 1,
|
||||
plateNumber: 'ABC-123',
|
||||
maintenanceVisits: []
|
||||
};
|
||||
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(existingVehicle);
|
||||
(prisma.vehicle.delete as any).mockResolvedValue(existingVehicle);
|
||||
|
||||
const result = await deleteVehicle(1);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(prisma.vehicle.delete).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if vehicle does not exist', async () => {
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
|
||||
|
||||
const result = await deleteVehicle(999);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('المركبة غير موجودة');
|
||||
expect(prisma.vehicle.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if vehicle has maintenance visits', async () => {
|
||||
const existingVehicle = {
|
||||
id: 1,
|
||||
plateNumber: 'ABC-123',
|
||||
maintenanceVisits: [{ id: 1 }, { id: 2 }]
|
||||
};
|
||||
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(existingVehicle);
|
||||
|
||||
const result = await deleteVehicle(1);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('لا يمكن حذف المركبة لأنها تحتوي على 2 زيارة صيانة');
|
||||
expect(prisma.vehicle.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVehicles', () => {
|
||||
it('should return vehicles with pagination', async () => {
|
||||
const mockVehicles = [
|
||||
{
|
||||
id: 1,
|
||||
plateNumber: 'ABC-123',
|
||||
manufacturer: 'تويوتا',
|
||||
owner: { id: 1, name: 'أحمد محمد' }
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
plateNumber: 'XYZ-789',
|
||||
manufacturer: 'هوندا',
|
||||
owner: { id: 2, name: 'محمد علي' }
|
||||
}
|
||||
];
|
||||
|
||||
(prisma.vehicle.findMany as any).mockResolvedValue(mockVehicles);
|
||||
(prisma.vehicle.count as any).mockResolvedValue(2);
|
||||
|
||||
const result = await getVehicles('', 1, 10);
|
||||
|
||||
expect(result.vehicles).toEqual(mockVehicles);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.totalPages).toBe(1);
|
||||
expect(prisma.vehicle.findMany).toHaveBeenCalledWith({
|
||||
where: {},
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdDate: 'desc' },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter by search query', async () => {
|
||||
(prisma.vehicle.findMany as any).mockResolvedValue([]);
|
||||
(prisma.vehicle.count as any).mockResolvedValue(0);
|
||||
|
||||
await getVehicles('تويوتا', 1, 10);
|
||||
|
||||
expect(prisma.vehicle.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [
|
||||
{ plateNumber: { contains: 'تويوتا' } },
|
||||
{ manufacturer: { contains: 'تويوتا' } },
|
||||
{ model: { contains: 'تويوتا' } },
|
||||
{ bodyType: { contains: 'تويوتا' } },
|
||||
{ owner: { name: { contains: 'تويوتا' } } },
|
||||
],
|
||||
},
|
||||
include: expect.any(Object),
|
||||
orderBy: { createdDate: 'desc' },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter by owner ID', async () => {
|
||||
(prisma.vehicle.findMany as any).mockResolvedValue([]);
|
||||
(prisma.vehicle.count as any).mockResolvedValue(0);
|
||||
|
||||
await getVehicles('', 1, 10, 5);
|
||||
|
||||
expect(prisma.vehicle.findMany).toHaveBeenCalledWith({
|
||||
where: { ownerId: 5 },
|
||||
include: expect.any(Object),
|
||||
orderBy: { createdDate: 'desc' },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVehicleById', () => {
|
||||
it('should return vehicle with full relationships', async () => {
|
||||
const mockVehicle = {
|
||||
id: 1,
|
||||
plateNumber: 'ABC-123',
|
||||
owner: { id: 1, name: 'أحمد محمد' },
|
||||
maintenanceVisits: [
|
||||
{ id: 1, visitDate: new Date(), cost: 100 }
|
||||
]
|
||||
};
|
||||
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(mockVehicle);
|
||||
|
||||
const result = await getVehicleById(1);
|
||||
|
||||
expect(result).toEqual(mockVehicle);
|
||||
expect(prisma.vehicle.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
include: {
|
||||
owner: true,
|
||||
maintenanceVisits: {
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { visitDate: 'desc' },
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null if vehicle not found', async () => {
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
|
||||
|
||||
const result = await getVehicleById(999);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVehicleStats', () => {
|
||||
it('should calculate vehicle statistics', async () => {
|
||||
const mockVehicle = {
|
||||
id: 1,
|
||||
suggestedNextVisitDate: new Date('2024-06-01'),
|
||||
maintenanceVisits: [
|
||||
{ cost: 150, visitDate: new Date('2024-03-01') }, // Most recent first (desc order)
|
||||
{ cost: 200, visitDate: new Date('2024-02-01') },
|
||||
{ cost: 100, visitDate: new Date('2024-01-01') },
|
||||
]
|
||||
};
|
||||
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(mockVehicle);
|
||||
|
||||
const result = await getVehicleStats(1);
|
||||
|
||||
expect(result).toEqual({
|
||||
totalVisits: 3,
|
||||
totalSpent: 450,
|
||||
lastVisitDate: new Date('2024-03-01'),
|
||||
nextSuggestedVisitDate: new Date('2024-06-01'),
|
||||
averageVisitCost: 150,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null if vehicle not found', async () => {
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
|
||||
|
||||
const result = await getVehicleStats(999);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle vehicle with no visits', async () => {
|
||||
const mockVehicle = {
|
||||
id: 1,
|
||||
suggestedNextVisitDate: null,
|
||||
maintenanceVisits: []
|
||||
};
|
||||
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(mockVehicle);
|
||||
|
||||
const result = await getVehicleStats(1);
|
||||
|
||||
expect(result).toEqual({
|
||||
totalVisits: 0,
|
||||
totalSpent: 0,
|
||||
lastVisitDate: undefined,
|
||||
nextSuggestedVisitDate: undefined,
|
||||
averageVisitCost: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchVehicles', () => {
|
||||
it('should search vehicles by query', async () => {
|
||||
const mockVehicles = [
|
||||
{
|
||||
id: 1,
|
||||
plateNumber: 'ABC-123',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
owner: { id: 1, name: 'أحمد محمد' }
|
||||
}
|
||||
];
|
||||
|
||||
(prisma.vehicle.findMany as any).mockResolvedValue(mockVehicles);
|
||||
|
||||
const result = await searchVehicles('تويوتا');
|
||||
|
||||
expect(result).toEqual(mockVehicles);
|
||||
expect(prisma.vehicle.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [
|
||||
{ plateNumber: { contains: 'تويوتا' } },
|
||||
{ manufacturer: { contains: 'تويوتا' } },
|
||||
{ model: { contains: 'تويوتا' } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
plateNumber: true,
|
||||
manufacturer: true,
|
||||
model: true,
|
||||
year: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { plateNumber: 'asc' },
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array for short queries', async () => {
|
||||
const result = await searchVehicles('a');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(prisma.vehicle.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
379
app/lib/__tests__/vehicle-validation.test.ts
Normal file
379
app/lib/__tests__/vehicle-validation.test.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateVehicle } from '../validation';
|
||||
|
||||
describe('Vehicle Validation', () => {
|
||||
describe('validateVehicle', () => {
|
||||
const validVehicleData = {
|
||||
plateNumber: 'ABC-123',
|
||||
bodyType: 'سيدان',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: 1,
|
||||
};
|
||||
|
||||
it('should validate a complete valid vehicle', () => {
|
||||
const result = validateVehicle(validVehicleData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should validate a minimal valid vehicle', () => {
|
||||
const minimalData = {
|
||||
plateNumber: 'ABC-123',
|
||||
bodyType: 'سيدان',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: 1,
|
||||
};
|
||||
|
||||
const result = validateVehicle(minimalData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('plateNumber validation', () => {
|
||||
it('should require plate number', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, plateNumber: '' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.plateNumber).toBe('رقم اللوحة مطلوب');
|
||||
});
|
||||
|
||||
it('should require plate number to not be just whitespace', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, plateNumber: ' ' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.plateNumber).toBe('رقم اللوحة مطلوب');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bodyType validation', () => {
|
||||
it('should require body type', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, bodyType: '' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.bodyType).toBe('نوع الهيكل مطلوب');
|
||||
});
|
||||
|
||||
it('should require body type to not be just whitespace', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, bodyType: ' ' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.bodyType).toBe('نوع الهيكل مطلوب');
|
||||
});
|
||||
});
|
||||
|
||||
describe('manufacturer validation', () => {
|
||||
it('should require manufacturer', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, manufacturer: '' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.manufacturer).toBe('الشركة المصنعة مطلوبة');
|
||||
});
|
||||
|
||||
it('should require manufacturer to not be just whitespace', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, manufacturer: ' ' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.manufacturer).toBe('الشركة المصنعة مطلوبة');
|
||||
});
|
||||
});
|
||||
|
||||
describe('model validation', () => {
|
||||
it('should require model', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, model: '' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.model).toBe('الموديل مطلوب');
|
||||
});
|
||||
|
||||
it('should require model to not be just whitespace', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, model: ' ' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.model).toBe('الموديل مطلوب');
|
||||
});
|
||||
});
|
||||
|
||||
describe('year validation', () => {
|
||||
it('should not validate undefined year (partial validation)', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, year: undefined as any });
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors.year).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject year below minimum', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, year: 1989 });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.year).toContain('السنة يجب أن تكون بين 1990');
|
||||
});
|
||||
|
||||
it('should reject year above maximum', () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const result = validateVehicle({ ...validVehicleData, year: currentYear + 2 });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.year).toContain('السنة يجب أن تكون بين');
|
||||
});
|
||||
|
||||
it('should accept current year', () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const result = validateVehicle({ ...validVehicleData, year: currentYear });
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept next year', () => {
|
||||
const nextYear = new Date().getFullYear() + 1;
|
||||
const result = validateVehicle({ ...validVehicleData, year: nextYear });
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transmission validation', () => {
|
||||
it('should require transmission', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, transmission: '' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.transmission).toBe('نوع ناقل الحركة غير صحيح');
|
||||
});
|
||||
|
||||
it('should accept valid transmission types', () => {
|
||||
const automaticResult = validateVehicle({ ...validVehicleData, transmission: 'Automatic' });
|
||||
const manualResult = validateVehicle({ ...validVehicleData, transmission: 'Manual' });
|
||||
|
||||
expect(automaticResult.isValid).toBe(true);
|
||||
expect(manualResult.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid transmission types', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, transmission: 'CVT' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.transmission).toBe('نوع ناقل الحركة غير صحيح');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fuel validation', () => {
|
||||
it('should require fuel type', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, fuel: '' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.fuel).toBe('نوع الوقود غير صحيح');
|
||||
});
|
||||
|
||||
it('should accept valid fuel types', () => {
|
||||
const validFuels = ['Gasoline', 'Diesel', 'Hybrid', 'Mild Hybrid', 'Electric'];
|
||||
|
||||
validFuels.forEach(fuel => {
|
||||
const result = validateVehicle({ ...validVehicleData, fuel });
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid fuel types', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, fuel: 'Nuclear' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.fuel).toBe('نوع الوقود غير صحيح');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cylinders validation', () => {
|
||||
it('should accept valid cylinder counts', () => {
|
||||
const validCylinders = [1, 2, 3, 4, 6, 8, 10, 12];
|
||||
|
||||
validCylinders.forEach(cylinders => {
|
||||
const result = validateVehicle({ ...validVehicleData, cylinders });
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject cylinder count below minimum', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, cylinders: 0 });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.cylinders).toContain('عدد الأسطوانات يجب أن يكون بين 1');
|
||||
});
|
||||
|
||||
it('should reject cylinder count above maximum', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, cylinders: 16 });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.cylinders).toContain('عدد الأسطوانات يجب أن يكون بين 1 و 12');
|
||||
});
|
||||
|
||||
it('should accept null/undefined cylinders', () => {
|
||||
const result1 = validateVehicle({ ...validVehicleData, cylinders: undefined });
|
||||
const result2 = validateVehicle({ ...validVehicleData, cylinders: null as any });
|
||||
|
||||
expect(result1.isValid).toBe(true);
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('engineDisplacement validation', () => {
|
||||
it('should accept valid engine displacements', () => {
|
||||
const validDisplacements = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0];
|
||||
|
||||
validDisplacements.forEach(engineDisplacement => {
|
||||
const result = validateVehicle({ ...validVehicleData, engineDisplacement });
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject engine displacement at zero', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, engineDisplacement: 0 });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.engineDisplacement).toContain('سعة المحرك يجب أن تكون بين 0.1');
|
||||
});
|
||||
|
||||
it('should reject engine displacement above maximum', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, engineDisplacement: 15.0 });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.engineDisplacement).toContain('سعة المحرك يجب أن تكون بين 0.1 و 10');
|
||||
});
|
||||
|
||||
it('should accept null/undefined engine displacement', () => {
|
||||
const result1 = validateVehicle({ ...validVehicleData, engineDisplacement: undefined });
|
||||
const result2 = validateVehicle({ ...validVehicleData, engineDisplacement: null as any });
|
||||
|
||||
expect(result1.isValid).toBe(true);
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useType validation', () => {
|
||||
it('should require use type', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, useType: '' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.useType).toBe('نوع الاستخدام غير صحيح');
|
||||
});
|
||||
|
||||
it('should accept valid use types', () => {
|
||||
const validUseTypes = ['personal', 'taxi', 'apps', 'loading', 'travel'];
|
||||
|
||||
validUseTypes.forEach(useType => {
|
||||
const result = validateVehicle({ ...validVehicleData, useType });
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid use types', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, useType: 'military' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.useType).toBe('نوع الاستخدام غير صحيح');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ownerId validation', () => {
|
||||
it('should not validate undefined owner ID (partial validation)', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, ownerId: undefined as any });
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors.ownerId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject zero owner ID', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, ownerId: 0 });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.ownerId).toBe('مالك المركبة مطلوب');
|
||||
});
|
||||
|
||||
it('should reject negative owner ID', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, ownerId: -1 });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.ownerId).toBe('مالك المركبة مطلوب');
|
||||
});
|
||||
|
||||
it('should accept positive owner ID', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, ownerId: 5 });
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple validation errors', () => {
|
||||
it('should return all validation errors', () => {
|
||||
const invalidData = {
|
||||
plateNumber: '',
|
||||
bodyType: '',
|
||||
manufacturer: '',
|
||||
model: '',
|
||||
year: 1980,
|
||||
transmission: 'Invalid',
|
||||
fuel: 'Invalid',
|
||||
cylinders: 20,
|
||||
engineDisplacement: 15.0,
|
||||
useType: 'Invalid',
|
||||
ownerId: 0,
|
||||
};
|
||||
|
||||
const result = validateVehicle(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(Object.keys(result.errors)).toContain('plateNumber');
|
||||
expect(Object.keys(result.errors)).toContain('bodyType');
|
||||
expect(Object.keys(result.errors)).toContain('manufacturer');
|
||||
expect(Object.keys(result.errors)).toContain('model');
|
||||
expect(Object.keys(result.errors)).toContain('year');
|
||||
expect(Object.keys(result.errors)).toContain('transmission');
|
||||
expect(Object.keys(result.errors)).toContain('fuel');
|
||||
expect(Object.keys(result.errors)).toContain('cylinders');
|
||||
expect(Object.keys(result.errors)).toContain('engineDisplacement');
|
||||
expect(Object.keys(result.errors)).toContain('useType');
|
||||
expect(Object.keys(result.errors)).toContain('ownerId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('partial validation', () => {
|
||||
it('should validate only provided fields', () => {
|
||||
const partialData = {
|
||||
plateNumber: 'XYZ-789',
|
||||
year: 2021,
|
||||
};
|
||||
|
||||
const result = validateVehicle(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should validate only provided fields with errors', () => {
|
||||
const partialData = {
|
||||
plateNumber: '',
|
||||
year: 1980,
|
||||
cylinders: 20,
|
||||
};
|
||||
|
||||
const result = validateVehicle(partialData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(Object.keys(result.errors)).toContain('plateNumber');
|
||||
expect(Object.keys(result.errors)).toContain('year');
|
||||
expect(Object.keys(result.errors)).toContain('cylinders');
|
||||
expect(Object.keys(result.errors)).not.toContain('manufacturer');
|
||||
expect(Object.keys(result.errors)).not.toContain('model');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
51
app/lib/auth-constants.ts
Normal file
51
app/lib/auth-constants.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Authentication configuration constants
|
||||
export const AUTH_CONFIG = {
|
||||
// Password requirements
|
||||
MIN_PASSWORD_LENGTH: 6,
|
||||
MAX_PASSWORD_LENGTH: 128,
|
||||
|
||||
// Session configuration
|
||||
SESSION_MAX_AGE: 60 * 60 * 24 * 30, // 30 days in seconds
|
||||
|
||||
// Rate limiting (for future implementation)
|
||||
MAX_LOGIN_ATTEMPTS: 5,
|
||||
LOGIN_ATTEMPT_WINDOW: 15 * 60 * 1000, // 15 minutes in milliseconds
|
||||
|
||||
// Cookie configuration
|
||||
COOKIE_NAME: "car_maintenance_session",
|
||||
} as const;
|
||||
|
||||
// Authentication error messages in Arabic
|
||||
export const AUTH_ERRORS = {
|
||||
INVALID_CREDENTIALS: "اسم المستخدم أو كلمة المرور غير صحيحة",
|
||||
ACCOUNT_INACTIVE: "الحساب غير مفعل",
|
||||
ACCOUNT_NOT_FOUND: "الحساب غير موجود",
|
||||
USERNAME_REQUIRED: "اسم المستخدم مطلوب",
|
||||
EMAIL_REQUIRED: "البريد الإلكتروني مطلوب",
|
||||
PASSWORD_REQUIRED: "كلمة المرور مطلوبة",
|
||||
NAME_REQUIRED: "الاسم مطلوب",
|
||||
PASSWORD_TOO_SHORT: "كلمة المرور يجب أن تكون 6 أحرف على الأقل",
|
||||
PASSWORD_MISMATCH: "كلمة المرور غير متطابقة",
|
||||
INVALID_EMAIL: "صيغة البريد الإلكتروني غير صحيحة",
|
||||
USERNAME_EXISTS: "اسم المستخدم موجود بالفعل",
|
||||
EMAIL_EXISTS: "البريد الإلكتروني موجود بالفعل",
|
||||
INSUFFICIENT_PERMISSIONS: "ليس لديك صلاحية للوصول إلى هذه الصفحة",
|
||||
SESSION_EXPIRED: "انتهت صلاحية الجلسة، يرجى تسجيل الدخول مرة أخرى",
|
||||
SIGNUP_DISABLED: "التسجيل غير متاح حالياً",
|
||||
} as const;
|
||||
|
||||
// Success messages in Arabic
|
||||
export const AUTH_SUCCESS = {
|
||||
LOGIN_SUCCESS: "تم تسجيل الدخول بنجاح",
|
||||
LOGOUT_SUCCESS: "تم تسجيل الخروج بنجاح",
|
||||
SIGNUP_SUCCESS: "تم إنشاء الحساب بنجاح",
|
||||
PASSWORD_CHANGED: "تم تغيير كلمة المرور بنجاح",
|
||||
PROFILE_UPDATED: "تم تحديث الملف الشخصي بنجاح",
|
||||
} as const;
|
||||
|
||||
// Validation patterns
|
||||
export const VALIDATION_PATTERNS = {
|
||||
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
USERNAME: /^[a-zA-Z0-9_]{3,20}$/,
|
||||
PHONE: /^[0-9+\-\s()]{10,15}$/,
|
||||
} as const;
|
||||
220
app/lib/auth-helpers.server.ts
Normal file
220
app/lib/auth-helpers.server.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { prisma } from "./db.server";
|
||||
import { verifyPassword, hashPassword } from "./auth.server";
|
||||
import type {
|
||||
SignInFormData,
|
||||
SignUpFormData,
|
||||
AuthResult,
|
||||
AuthLevel,
|
||||
SafeUser,
|
||||
RouteProtectionOptions
|
||||
} from "~/types/auth";
|
||||
import { AUTH_LEVELS, USER_STATUS } from "~/types/auth";
|
||||
import { AUTH_ERRORS, AUTH_CONFIG, VALIDATION_PATTERNS } from "./auth-constants";
|
||||
|
||||
// Authentication validation functions
|
||||
export async function validateSignIn(formData: SignInFormData): Promise<AuthResult> {
|
||||
const { usernameOrEmail, password } = formData;
|
||||
|
||||
// Find user by username or email
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: usernameOrEmail },
|
||||
{ email: usernameOrEmail },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ message: AUTH_ERRORS.INVALID_CREDENTIALS }],
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (user.status !== USER_STATUS.ACTIVE) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ message: AUTH_ERRORS.ACCOUNT_INACTIVE }],
|
||||
};
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await verifyPassword(password, user.password);
|
||||
if (!isValidPassword) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ message: AUTH_ERRORS.INVALID_CREDENTIALS }],
|
||||
};
|
||||
}
|
||||
|
||||
// Return success with safe user data
|
||||
const { password: _, ...safeUser } = user;
|
||||
return {
|
||||
success: true,
|
||||
user: safeUser,
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateSignUp(formData: SignUpFormData): Promise<AuthResult> {
|
||||
const { name, username, email, password, confirmPassword } = formData;
|
||||
const errors: { field?: string; message: string }[] = [];
|
||||
|
||||
// Validate required fields
|
||||
if (!name.trim()) {
|
||||
errors.push({ field: "name", message: AUTH_ERRORS.NAME_REQUIRED });
|
||||
}
|
||||
|
||||
if (!username.trim()) {
|
||||
errors.push({ field: "username", message: AUTH_ERRORS.USERNAME_REQUIRED });
|
||||
}
|
||||
|
||||
if (!email.trim()) {
|
||||
errors.push({ field: "email", message: AUTH_ERRORS.EMAIL_REQUIRED });
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
errors.push({ field: "password", message: AUTH_ERRORS.PASSWORD_REQUIRED });
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
errors.push({ field: "confirmPassword", message: AUTH_ERRORS.PASSWORD_MISMATCH });
|
||||
}
|
||||
|
||||
// Validate password strength
|
||||
if (password && password.length < AUTH_CONFIG.MIN_PASSWORD_LENGTH) {
|
||||
errors.push({ field: "password", message: AUTH_ERRORS.PASSWORD_TOO_SHORT });
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if (email && !VALIDATION_PATTERNS.EMAIL.test(email)) {
|
||||
errors.push({ field: "email", message: AUTH_ERRORS.INVALID_EMAIL });
|
||||
}
|
||||
|
||||
// Check for existing username
|
||||
if (username) {
|
||||
const existingUsername = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
if (existingUsername) {
|
||||
errors.push({ field: "username", message: AUTH_ERRORS.USERNAME_EXISTS });
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing email
|
||||
if (email) {
|
||||
const existingEmail = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
if (existingEmail) {
|
||||
errors.push({ field: "email", message: AUTH_ERRORS.EMAIL_EXISTS });
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// User creation function
|
||||
export async function createUser(formData: SignUpFormData): Promise<SafeUser> {
|
||||
const { name, username, email, password } = formData;
|
||||
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: name.trim(),
|
||||
username: username.trim(),
|
||||
email: email.trim(),
|
||||
password: hashedPassword,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
authLevel: AUTH_LEVELS.ADMIN, // First user becomes admin
|
||||
createdDate: new Date(),
|
||||
editDate: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const { password: _, ...safeUser } = user;
|
||||
return safeUser;
|
||||
}
|
||||
|
||||
// Authorization helper functions
|
||||
export function hasPermission(userAuthLevel: AuthLevel, requiredAuthLevel: AuthLevel): boolean {
|
||||
return userAuthLevel <= requiredAuthLevel;
|
||||
}
|
||||
|
||||
export function canAccessUserManagement(userAuthLevel: AuthLevel): boolean {
|
||||
return userAuthLevel <= AUTH_LEVELS.ADMIN;
|
||||
}
|
||||
|
||||
export function canViewAllUsers(userAuthLevel: AuthLevel): boolean {
|
||||
return userAuthLevel === AUTH_LEVELS.SUPERADMIN;
|
||||
}
|
||||
|
||||
export function canCreateUsers(userAuthLevel: AuthLevel): boolean {
|
||||
return userAuthLevel <= AUTH_LEVELS.ADMIN;
|
||||
}
|
||||
|
||||
// Route protection middleware
|
||||
export async function requireAuthLevel(
|
||||
request: Request,
|
||||
requiredAuthLevel: AuthLevel,
|
||||
options: RouteProtectionOptions = {}
|
||||
) {
|
||||
const { allowInactive = false, redirectTo = "/signin" } = options;
|
||||
|
||||
// Get user from session
|
||||
const userId = await getUserId(request);
|
||||
if (!userId) {
|
||||
throw redirect(redirectTo);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw redirect(redirectTo);
|
||||
}
|
||||
|
||||
// Check if user is active (unless explicitly allowed)
|
||||
if (!allowInactive && user.status !== USER_STATUS.ACTIVE) {
|
||||
throw redirect("/signin?error=account_inactive");
|
||||
}
|
||||
|
||||
// Check authorization level
|
||||
if (!hasPermission(user.authLevel as AuthLevel, requiredAuthLevel)) {
|
||||
throw redirect("/dashboard?error=insufficient_permissions");
|
||||
}
|
||||
|
||||
const { password: _, ...safeUser } = user;
|
||||
return safeUser;
|
||||
}
|
||||
|
||||
// Check if signup should be allowed (only when no admin users exist)
|
||||
export async function isSignupAllowed(): Promise<boolean> {
|
||||
const adminCount = await prisma.user.count({
|
||||
where: {
|
||||
authLevel: {
|
||||
in: [AUTH_LEVELS.SUPERADMIN, AUTH_LEVELS.ADMIN],
|
||||
},
|
||||
status: USER_STATUS.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
return adminCount === 0;
|
||||
}
|
||||
|
||||
// Import getUserId function
|
||||
async function getUserId(request: Request): Promise<number | null> {
|
||||
const { getUserId: getSessionUserId } = await import("./auth.server");
|
||||
return getSessionUserId(request);
|
||||
}
|
||||
173
app/lib/auth-middleware.server.ts
Normal file
173
app/lib/auth-middleware.server.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { getUser, requireUserId } from "./auth.server";
|
||||
import { requireAuthLevel } from "./auth-helpers.server";
|
||||
import type { AuthLevel, SafeUser, RouteProtectionOptions } from "~/types/auth";
|
||||
import { AUTH_LEVELS, USER_STATUS } from "~/types/auth";
|
||||
import { AUTH_ERRORS } from "./auth-constants";
|
||||
|
||||
// Enhanced middleware for protecting routes that require authentication
|
||||
export async function requireAuthentication(
|
||||
request: Request,
|
||||
options: RouteProtectionOptions = {}
|
||||
): Promise<SafeUser> {
|
||||
const { allowInactive = false, redirectTo = "/signin" } = options;
|
||||
|
||||
await requireUserId(request, redirectTo);
|
||||
const user = await getUser(request);
|
||||
|
||||
if (!user) {
|
||||
throw redirect(redirectTo);
|
||||
}
|
||||
|
||||
// Check if user is active (unless explicitly allowed)
|
||||
if (!allowInactive && user.status !== USER_STATUS.ACTIVE) {
|
||||
throw redirect("/signin?error=account_inactive");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
// Middleware for protecting admin routes (admin and superadmin)
|
||||
export async function requireAdmin(
|
||||
request: Request,
|
||||
options: RouteProtectionOptions = {}
|
||||
): Promise<SafeUser> {
|
||||
return requireAuthLevel(request, AUTH_LEVELS.ADMIN, options);
|
||||
}
|
||||
|
||||
// Middleware for protecting superadmin routes
|
||||
export async function requireSuperAdmin(
|
||||
request: Request,
|
||||
options: RouteProtectionOptions = {}
|
||||
): Promise<SafeUser> {
|
||||
return requireAuthLevel(request, AUTH_LEVELS.SUPERADMIN, options);
|
||||
}
|
||||
|
||||
// Middleware for protecting routes with custom auth level
|
||||
export async function requireAuth(
|
||||
request: Request,
|
||||
authLevel: AuthLevel,
|
||||
options: RouteProtectionOptions = {}
|
||||
): Promise<SafeUser> {
|
||||
return requireAuthLevel(request, authLevel, options);
|
||||
}
|
||||
|
||||
// Middleware for redirecting authenticated users away from auth pages
|
||||
export async function redirectIfAuthenticated(
|
||||
request: Request,
|
||||
redirectTo: string = "/dashboard"
|
||||
) {
|
||||
const user = await getUser(request);
|
||||
if (user && user.status === USER_STATUS.ACTIVE) {
|
||||
throw redirect(redirectTo);
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware for optional authentication (user may or may not be logged in)
|
||||
export async function getOptionalUser(request: Request): Promise<SafeUser | null> {
|
||||
try {
|
||||
const user = await getUser(request);
|
||||
return user && user.status === USER_STATUS.ACTIVE ? user : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Session validation middleware
|
||||
export async function validateSession(request: Request): Promise<{
|
||||
isValid: boolean;
|
||||
user: SafeUser | null;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const user = await getUser(request);
|
||||
if (!user) {
|
||||
return {
|
||||
isValid: false,
|
||||
user: null,
|
||||
error: "no_user",
|
||||
};
|
||||
}
|
||||
|
||||
if (user.status !== USER_STATUS.ACTIVE) {
|
||||
return {
|
||||
isValid: false,
|
||||
user: null,
|
||||
error: "inactive_user",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
user,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
isValid: false,
|
||||
user: null,
|
||||
error: "session_error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Route-specific protection functions
|
||||
export async function protectUserManagementRoute(request: Request): Promise<SafeUser> {
|
||||
const user = await requireAdmin(request);
|
||||
|
||||
// Additional business logic: ensure user can manage users
|
||||
if (user.authLevel > AUTH_LEVELS.ADMIN) {
|
||||
throw redirect("/dashboard?error=insufficient_permissions");
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function protectFinancialRoute(request: Request): Promise<SafeUser> {
|
||||
// Financial routes require at least admin level
|
||||
return requireAdmin(request);
|
||||
}
|
||||
|
||||
export async function protectCustomerRoute(request: Request): Promise<SafeUser> {
|
||||
// Customer routes require authentication but any auth level can access
|
||||
return requireAuthentication(request);
|
||||
}
|
||||
|
||||
export async function protectVehicleRoute(request: Request): Promise<SafeUser> {
|
||||
// Vehicle routes require authentication but any auth level can access
|
||||
return requireAuthentication(request);
|
||||
}
|
||||
|
||||
export async function protectMaintenanceRoute(request: Request): Promise<SafeUser> {
|
||||
// Maintenance routes require authentication but any auth level can access
|
||||
return requireAuthentication(request);
|
||||
}
|
||||
|
||||
// Utility function to check specific permissions
|
||||
export function checkPermission(
|
||||
user: SafeUser,
|
||||
permission: 'view_all_users' | 'create_users' | 'manage_finances' | 'view_reports'
|
||||
): boolean {
|
||||
switch (permission) {
|
||||
case 'view_all_users':
|
||||
return user.authLevel === AUTH_LEVELS.SUPERADMIN;
|
||||
case 'create_users':
|
||||
return user.authLevel <= AUTH_LEVELS.ADMIN;
|
||||
case 'manage_finances':
|
||||
return user.authLevel <= AUTH_LEVELS.ADMIN;
|
||||
case 'view_reports':
|
||||
return user.authLevel <= AUTH_LEVELS.ADMIN;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Error handling for unauthorized access
|
||||
export function createUnauthorizedResponse(message?: string) {
|
||||
const errorMessage = message || AUTH_ERRORS.INSUFFICIENT_PERMISSIONS;
|
||||
return new Response(errorMessage, {
|
||||
status: 403,
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8'
|
||||
}
|
||||
});
|
||||
}
|
||||
113
app/lib/auth.server.ts
Normal file
113
app/lib/auth.server.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
import { createCookieSessionStorage, redirect } from "@remix-run/node";
|
||||
import { prisma } from "./db.server";
|
||||
import type { User } from "@prisma/client";
|
||||
|
||||
// Session configuration
|
||||
const sessionSecret = process.env.SESSION_SECRET;
|
||||
if (!sessionSecret) {
|
||||
throw new Error("SESSION_SECRET must be set");
|
||||
}
|
||||
|
||||
// Create session storage
|
||||
const storage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
name: "car_maintenance_session",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secrets: [sessionSecret],
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 days
|
||||
httpOnly: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Password hashing utilities
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, 12);
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
password: string,
|
||||
hashedPassword: string
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(password, hashedPassword);
|
||||
}
|
||||
|
||||
// Session management functions
|
||||
export async function createUserSession(
|
||||
userId: number,
|
||||
redirectTo: string = "/dashboard"
|
||||
) {
|
||||
const session = await storage.getSession();
|
||||
session.set("userId", userId);
|
||||
return redirect(redirectTo, {
|
||||
headers: {
|
||||
"Set-Cookie": await storage.commitSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserSession(request: Request) {
|
||||
return storage.getSession(request.headers.get("Cookie"));
|
||||
}
|
||||
|
||||
export async function getUserId(request: Request): Promise<number | null> {
|
||||
const session = await getUserSession(request);
|
||||
const userId = session.get("userId");
|
||||
if (!userId || typeof userId !== "number") return null;
|
||||
return userId;
|
||||
}
|
||||
|
||||
export async function requireUserId(
|
||||
request: Request,
|
||||
redirectTo: string = new URL(request.url).pathname
|
||||
) {
|
||||
const userId = await getUserId(request);
|
||||
if (!userId) {
|
||||
const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
|
||||
throw redirect(`/signin?${searchParams}`);
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
export async function getUser(request: Request): Promise<Omit<User, 'password'> | null> {
|
||||
const userId = await getUserId(request);
|
||||
if (!userId) return null;
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
status: true,
|
||||
authLevel: true,
|
||||
createdDate: true,
|
||||
editDate: true,
|
||||
},
|
||||
});
|
||||
return user;
|
||||
} catch {
|
||||
throw logout(request);
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireUser(request: Request) {
|
||||
const user = await getUser(request);
|
||||
if (!user) {
|
||||
throw logout(request);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function logout(request: Request) {
|
||||
const session = await getUserSession(request);
|
||||
return redirect("/signin", {
|
||||
headers: {
|
||||
"Set-Cookie": await storage.destroySession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
257
app/lib/car-dataset-management.server.ts
Normal file
257
app/lib/car-dataset-management.server.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { prisma } from "~/lib/db.server";
|
||||
import type { CreateCarDatasetData, UpdateCarDatasetData } from "~/types/database";
|
||||
|
||||
// Get all unique manufacturers
|
||||
export async function getManufacturers() {
|
||||
try {
|
||||
const manufacturers = await prisma.carDataset.findMany({
|
||||
where: { isActive: true },
|
||||
select: { manufacturer: true },
|
||||
distinct: ['manufacturer'],
|
||||
orderBy: { manufacturer: 'asc' },
|
||||
});
|
||||
|
||||
return manufacturers.map(item => item.manufacturer);
|
||||
} catch (error) {
|
||||
console.error("Error fetching manufacturers:", error);
|
||||
throw new Error("فشل في جلب الشركات المصنعة");
|
||||
}
|
||||
}
|
||||
|
||||
// Get all models for a specific manufacturer
|
||||
export async function getModelsByManufacturer(manufacturer: string) {
|
||||
try {
|
||||
const models = await prisma.carDataset.findMany({
|
||||
where: {
|
||||
manufacturer,
|
||||
isActive: true
|
||||
},
|
||||
select: { model: true, bodyType: true },
|
||||
orderBy: { model: 'asc' },
|
||||
});
|
||||
|
||||
return models;
|
||||
} catch (error) {
|
||||
console.error("Error fetching models:", error);
|
||||
throw new Error("فشل في جلب الموديلات");
|
||||
}
|
||||
}
|
||||
|
||||
// Get body type for a specific manufacturer and model
|
||||
export async function getBodyType(manufacturer: string, model: string) {
|
||||
try {
|
||||
const carData = await prisma.carDataset.findFirst({
|
||||
where: {
|
||||
manufacturer,
|
||||
model,
|
||||
isActive: true
|
||||
},
|
||||
select: { bodyType: true },
|
||||
});
|
||||
|
||||
return carData?.bodyType || null;
|
||||
} catch (error) {
|
||||
console.error("Error fetching body type:", error);
|
||||
throw new Error("فشل في جلب نوع الهيكل");
|
||||
}
|
||||
}
|
||||
|
||||
// Get all car dataset entries with pagination
|
||||
export async function getCarDataset(
|
||||
searchQuery: string = "",
|
||||
page: number = 1,
|
||||
limit: number = 50,
|
||||
manufacturer?: string
|
||||
) {
|
||||
try {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const where = {
|
||||
AND: [
|
||||
manufacturer ? { manufacturer } : {},
|
||||
searchQuery ? {
|
||||
OR: [
|
||||
{ manufacturer: { contains: searchQuery, mode: 'insensitive' as const } },
|
||||
{ model: { contains: searchQuery, mode: 'insensitive' as const } },
|
||||
{ bodyType: { contains: searchQuery, mode: 'insensitive' as const } },
|
||||
]
|
||||
} : {}
|
||||
]
|
||||
};
|
||||
|
||||
const [carDataset, total] = await Promise.all([
|
||||
prisma.carDataset.findMany({
|
||||
where,
|
||||
orderBy: [
|
||||
{ manufacturer: 'asc' },
|
||||
{ model: 'asc' }
|
||||
],
|
||||
skip: offset,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.carDataset.count({ where })
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
carDataset,
|
||||
total,
|
||||
totalPages,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching car dataset:", error);
|
||||
throw new Error("فشل في جلب بيانات السيارات");
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new car dataset entry
|
||||
export async function createCarDataset(data: CreateCarDatasetData) {
|
||||
try {
|
||||
const carDataset = await prisma.carDataset.create({
|
||||
data: {
|
||||
manufacturer: data.manufacturer.trim(),
|
||||
model: data.model.trim(),
|
||||
bodyType: data.bodyType.trim(),
|
||||
isActive: data.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, carDataset };
|
||||
} catch (error: any) {
|
||||
console.error("Error creating car dataset:", error);
|
||||
|
||||
if (error.code === 'P2002') {
|
||||
return {
|
||||
success: false,
|
||||
error: "هذا الموديل موجود بالفعل لهذه الشركة المصنعة"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "فشل في إنشاء بيانات السيارة"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Update a car dataset entry
|
||||
export async function updateCarDataset(id: number, data: UpdateCarDatasetData) {
|
||||
try {
|
||||
const carDataset = await prisma.carDataset.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(data.manufacturer && { manufacturer: data.manufacturer.trim() }),
|
||||
...(data.model && { model: data.model.trim() }),
|
||||
...(data.bodyType && { bodyType: data.bodyType.trim() }),
|
||||
...(data.isActive !== undefined && { isActive: data.isActive }),
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, carDataset };
|
||||
} catch (error: any) {
|
||||
console.error("Error updating car dataset:", error);
|
||||
|
||||
if (error.code === 'P2002') {
|
||||
return {
|
||||
success: false,
|
||||
error: "هذا الموديل موجود بالفعل لهذه الشركة المصنعة"
|
||||
};
|
||||
}
|
||||
|
||||
if (error.code === 'P2025') {
|
||||
return {
|
||||
success: false,
|
||||
error: "بيانات السيارة غير موجودة"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "فشل في تحديث بيانات السيارة"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Delete a car dataset entry
|
||||
export async function deleteCarDataset(id: number) {
|
||||
try {
|
||||
await prisma.carDataset.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting car dataset:", error);
|
||||
|
||||
if (error.code === 'P2025') {
|
||||
return {
|
||||
success: false,
|
||||
error: "بيانات السيارة غير موجودة"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "فشل في حذف بيانات السيارة"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Get car dataset entry by ID
|
||||
export async function getCarDatasetById(id: number) {
|
||||
try {
|
||||
const carDataset = await prisma.carDataset.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return carDataset;
|
||||
} catch (error) {
|
||||
console.error("Error fetching car dataset by ID:", error);
|
||||
throw new Error("فشل في جلب بيانات السيارة");
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk import car dataset
|
||||
export async function bulkImportCarDataset(data: CreateCarDatasetData[]) {
|
||||
try {
|
||||
const results = await prisma.$transaction(async (tx) => {
|
||||
const created = [];
|
||||
const errors = [];
|
||||
|
||||
for (const item of data) {
|
||||
try {
|
||||
const carDataset = await tx.carDataset.create({
|
||||
data: {
|
||||
manufacturer: item.manufacturer.trim(),
|
||||
model: item.model.trim(),
|
||||
bodyType: item.bodyType.trim(),
|
||||
isActive: item.isActive ?? true,
|
||||
},
|
||||
});
|
||||
created.push(carDataset);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'P2002') {
|
||||
errors.push(`${item.manufacturer} ${item.model} - موجود بالفعل`);
|
||||
} else {
|
||||
errors.push(`${item.manufacturer} ${item.model} - خطأ غير معروف`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { created, errors };
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
created: results.created.length,
|
||||
errors: results.errors
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error bulk importing car dataset:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: "فشل في استيراد بيانات السيارات"
|
||||
};
|
||||
}
|
||||
}
|
||||
173
app/lib/constants.ts
Normal file
173
app/lib/constants.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
// Authentication levels
|
||||
export const AUTH_LEVELS = {
|
||||
SUPERADMIN: 1,
|
||||
ADMIN: 2,
|
||||
USER: 3,
|
||||
} as const;
|
||||
|
||||
export const AUTH_LEVEL_NAMES = {
|
||||
[AUTH_LEVELS.SUPERADMIN]: 'مدير عام',
|
||||
[AUTH_LEVELS.ADMIN]: 'مدير',
|
||||
[AUTH_LEVELS.USER]: 'مستخدم',
|
||||
} as const;
|
||||
|
||||
// User status options
|
||||
export const USER_STATUS = {
|
||||
ACTIVE: 'active',
|
||||
INACTIVE: 'inactive',
|
||||
} as const;
|
||||
|
||||
export const USER_STATUS_NAMES = {
|
||||
[USER_STATUS.ACTIVE]: 'نشط',
|
||||
[USER_STATUS.INACTIVE]: 'غير نشط',
|
||||
} as const;
|
||||
|
||||
// Vehicle transmission options
|
||||
export const TRANSMISSION_TYPES = [
|
||||
{ value: 'Automatic', label: 'أوتوماتيك' },
|
||||
{ value: 'Manual', label: 'يدوي' },
|
||||
] as const;
|
||||
|
||||
// Vehicle fuel types
|
||||
export const FUEL_TYPES = [
|
||||
{ value: 'Gasoline', label: 'بنزين' },
|
||||
{ value: 'Diesel', label: 'ديزل' },
|
||||
{ value: 'Hybrid', label: 'هجين' },
|
||||
{ value: 'Mild Hybrid', label: 'هجين خفيف' },
|
||||
{ value: 'Electric', label: 'كهربائي' },
|
||||
] as const;
|
||||
|
||||
// Vehicle use types
|
||||
export const USE_TYPES = [
|
||||
{ value: 'personal', label: 'شخصي' },
|
||||
{ value: 'taxi', label: 'تاكسي' },
|
||||
{ value: 'apps', label: 'تطبيقات' },
|
||||
{ value: 'loading', label: 'نقل' },
|
||||
{ value: 'travel', label: 'سفر' },
|
||||
] as const;
|
||||
|
||||
// Vehicle body types (common in Saudi Arabia)
|
||||
export const BODY_TYPES = [
|
||||
{ value: 'سيدان', label: 'سيدان' },
|
||||
{ value: 'هاتشباك', label: 'هاتشباك' },
|
||||
{ value: 'SUV', label: 'SUV' },
|
||||
{ value: 'كروس أوفر', label: 'كروس أوفر' },
|
||||
{ value: 'بيك أب', label: 'بيك أب' },
|
||||
{ value: 'كوبيه', label: 'كوبيه' },
|
||||
{ value: 'كونفرتيبل', label: 'كونفرتيبل' },
|
||||
{ value: 'فان', label: 'فان' },
|
||||
{ value: 'شاحنة', label: 'شاحنة' },
|
||||
] as const;
|
||||
|
||||
// Popular car manufacturers in Saudi Arabia
|
||||
export const MANUFACTURERS = [
|
||||
{ value: 'تويوتا', label: 'تويوتا' },
|
||||
{ value: 'هيونداي', label: 'هيونداي' },
|
||||
{ value: 'نيسان', label: 'نيسان' },
|
||||
{ value: 'كيا', label: 'كيا' },
|
||||
{ value: 'هوندا', label: 'هوندا' },
|
||||
{ value: 'فورد', label: 'فورد' },
|
||||
{ value: 'شيفروليه', label: 'شيفروليه' },
|
||||
{ value: 'مازda', label: 'مازda' },
|
||||
{ value: 'ميتسوبيشي', label: 'ميتسوبيشي' },
|
||||
{ value: 'سوزوكي', label: 'سوزوكي' },
|
||||
{ value: 'لكزس', label: 'لكزس' },
|
||||
{ value: 'إنفينيتي', label: 'إنفينيتي' },
|
||||
{ value: 'جينيسيس', label: 'جينيسيس' },
|
||||
{ value: 'BMW', label: 'BMW' },
|
||||
{ value: 'مرسيدس بنز', label: 'مرسيدس بنز' },
|
||||
{ value: 'أودي', label: 'أودي' },
|
||||
{ value: 'فولكس واجن', label: 'فولكس واجن' },
|
||||
{ value: 'جيب', label: 'جيب' },
|
||||
{ value: 'لاند روفر', label: 'لاند روفر' },
|
||||
{ value: 'كاديلاك', label: 'كاديلاك' },
|
||||
{ value: 'لينكولن', label: 'لينكولن' },
|
||||
{ value: 'جاكوار', label: 'جاكوار' },
|
||||
{ value: 'بورش', label: 'بورش' },
|
||||
{ value: 'فيراري', label: 'فيراري' },
|
||||
{ value: 'لامبورغيني', label: 'لامبورغيني' },
|
||||
{ value: 'بنتلي', label: 'بنتلي' },
|
||||
{ value: 'رولز رويس', label: 'رولز رويس' },
|
||||
{ value: 'أخرى', label: 'أخرى' },
|
||||
] as const;
|
||||
|
||||
// Payment status options
|
||||
export const PAYMENT_STATUS = {
|
||||
PENDING: 'pending',
|
||||
PAID: 'paid',
|
||||
PARTIAL: 'partial',
|
||||
CANCELLED: 'cancelled',
|
||||
} as const;
|
||||
|
||||
export const PAYMENT_STATUS_NAMES = {
|
||||
[PAYMENT_STATUS.PENDING]: 'معلق',
|
||||
[PAYMENT_STATUS.PAID]: 'مدفوع',
|
||||
[PAYMENT_STATUS.PARTIAL]: 'مدفوع جزئياً',
|
||||
[PAYMENT_STATUS.CANCELLED]: 'ملغي',
|
||||
} as const;
|
||||
|
||||
// Maintenance visit delay options (in months)
|
||||
export const VISIT_DELAY_OPTIONS = [
|
||||
{ value: 1, label: 'شهر واحد' },
|
||||
{ value: 2, label: 'شهرين' },
|
||||
{ value: 3, label: 'ثلاثة أشهر' },
|
||||
{ value: 4, label: 'أربعة أشهر' },
|
||||
] as const;
|
||||
|
||||
// Common maintenance types
|
||||
export const MAINTENANCE_TYPES = [
|
||||
{ value: 'تغيير زيت', label: 'تغيير زيت' },
|
||||
{ value: 'فحص دوري', label: 'فحص دوري' },
|
||||
{ value: 'تغيير فلاتر', label: 'تغيير فلاتر' },
|
||||
{ value: 'فحص فرامل', label: 'فحص فرامل' },
|
||||
{ value: 'تغيير إطارات', label: 'تغيير إطارات' },
|
||||
{ value: 'فحص بطارية', label: 'فحص بطارية' },
|
||||
{ value: 'تنظيف مكيف', label: 'تنظيف مكيف' },
|
||||
{ value: 'فحص محرك', label: 'فحص محرك' },
|
||||
{ value: 'تغيير شمعات', label: 'تغيير شمعات' },
|
||||
{ value: 'فحص ناقل حركة', label: 'فحص ناقل حركة' },
|
||||
{ value: 'إصلاح عام', label: 'إصلاح عام' },
|
||||
{ value: 'أخرى', label: 'أخرى' },
|
||||
] as const;
|
||||
|
||||
// Expense categories
|
||||
export const EXPENSE_CATEGORIES = [
|
||||
{ value: 'قطع غيار', label: 'قطع غيار' },
|
||||
{ value: 'أدوات', label: 'أدوات' },
|
||||
{ value: 'إيجار', label: 'إيجار' },
|
||||
{ value: 'كهرباء', label: 'كهرباء' },
|
||||
{ value: 'ماء', label: 'ماء' },
|
||||
{ value: 'رواتب', label: 'رواتب' },
|
||||
{ value: 'تأمين', label: 'تأمين' },
|
||||
{ value: 'وقود', label: 'وقود' },
|
||||
{ value: 'صيانة معدات', label: 'صيانة معدات' },
|
||||
{ value: 'تسويق', label: 'تسويق' },
|
||||
{ value: 'مصاريف إدارية', label: 'مصاريف إدارية' },
|
||||
{ value: 'أخرى', label: 'أخرى' },
|
||||
] as const;
|
||||
|
||||
// Date format options
|
||||
export const DATE_FORMATS = {
|
||||
SHORT: 'dd/MM/yyyy',
|
||||
LONG: 'dd MMMM yyyy',
|
||||
WITH_TIME: 'dd/MM/yyyy HH:mm',
|
||||
} as const;
|
||||
|
||||
// Pagination defaults
|
||||
export const PAGINATION = {
|
||||
DEFAULT_PAGE_SIZE: 10,
|
||||
PAGE_SIZE_OPTIONS: [10, 25, 50, 100],
|
||||
} as const;
|
||||
|
||||
// Validation constants
|
||||
export const VALIDATION = {
|
||||
MIN_PASSWORD_LENGTH: 6,
|
||||
MAX_NAME_LENGTH: 100,
|
||||
MAX_DESCRIPTION_LENGTH: 500,
|
||||
MIN_YEAR: 1990,
|
||||
MAX_YEAR: new Date().getFullYear() + 1,
|
||||
MAX_CYLINDERS: 12,
|
||||
MAX_ENGINE_DISPLACEMENT: 10.0,
|
||||
MIN_COST: 0,
|
||||
MAX_COST: 999999.99,
|
||||
} as const;
|
||||
326
app/lib/customer-management.server.ts
Normal file
326
app/lib/customer-management.server.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { prisma } from "./db.server";
|
||||
import type { Customer } from "@prisma/client";
|
||||
import type { CreateCustomerData, UpdateCustomerData, CustomerWithVehicles } from "~/types/database";
|
||||
|
||||
// Get all customers with search and pagination
|
||||
export async function getCustomers(
|
||||
searchQuery?: string,
|
||||
page: number = 1,
|
||||
limit: number = 10
|
||||
): Promise<{
|
||||
customers: CustomerWithVehicles[];
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build where clause for search
|
||||
const whereClause: any = {};
|
||||
|
||||
if (searchQuery) {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
whereClause.OR = [
|
||||
{ name: { contains: searchLower } },
|
||||
{ phone: { contains: searchLower } },
|
||||
{ email: { contains: searchLower } },
|
||||
{ address: { contains: searchLower } },
|
||||
];
|
||||
}
|
||||
|
||||
const [customers, total] = await Promise.all([
|
||||
prisma.customer.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
vehicles: {
|
||||
select: {
|
||||
id: true,
|
||||
plateNumber: true,
|
||||
manufacturer: true,
|
||||
model: true,
|
||||
year: true,
|
||||
lastVisitDate: true,
|
||||
suggestedNextVisitDate: true,
|
||||
},
|
||||
},
|
||||
maintenanceVisits: {
|
||||
select: {
|
||||
id: true,
|
||||
visitDate: true,
|
||||
cost: true,
|
||||
maintenanceJobs: true,
|
||||
},
|
||||
orderBy: { visitDate: 'desc' },
|
||||
take: 5, // Only get last 5 visits for performance
|
||||
},
|
||||
},
|
||||
orderBy: { createdDate: 'desc' },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.customer.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
return {
|
||||
customers,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
// Get customer by ID with full relationships
|
||||
export async function getCustomerById(id: number): Promise<CustomerWithVehicles | null> {
|
||||
return await prisma.customer.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
vehicles: {
|
||||
orderBy: { createdDate: 'desc' },
|
||||
},
|
||||
maintenanceVisits: {
|
||||
include: {
|
||||
vehicle: {
|
||||
select: {
|
||||
id: true,
|
||||
plateNumber: true,
|
||||
manufacturer: true,
|
||||
model: true,
|
||||
year: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { visitDate: 'desc' },
|
||||
take: 3, // Only get latest 3 visits for the enhanced view
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create new customer
|
||||
export async function createCustomer(
|
||||
customerData: CreateCustomerData
|
||||
): Promise<{ success: boolean; customer?: Customer; error?: string }> {
|
||||
try {
|
||||
// Check if customer with same phone or email already exists (if provided)
|
||||
if (customerData.phone || customerData.email) {
|
||||
const existingCustomer = await prisma.customer.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
customerData.phone ? { phone: customerData.phone } : {},
|
||||
customerData.email ? { email: customerData.email } : {},
|
||||
].filter(condition => Object.keys(condition).length > 0),
|
||||
},
|
||||
});
|
||||
|
||||
if (existingCustomer) {
|
||||
if (existingCustomer.phone === customerData.phone) {
|
||||
return { success: false, error: "رقم الهاتف موجود بالفعل" };
|
||||
}
|
||||
if (existingCustomer.email === customerData.email) {
|
||||
return { success: false, error: "البريد الإلكتروني موجود بالفعل" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create customer
|
||||
const customer = await prisma.customer.create({
|
||||
data: {
|
||||
name: customerData.name.trim(),
|
||||
phone: customerData.phone?.trim() || null,
|
||||
email: customerData.email?.trim() || null,
|
||||
address: customerData.address?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, customer };
|
||||
} catch (error) {
|
||||
console.error("Error creating customer:", error);
|
||||
return { success: false, error: "حدث خطأ أثناء إنشاء العميل" };
|
||||
}
|
||||
}
|
||||
|
||||
// Update customer
|
||||
export async function updateCustomer(
|
||||
id: number,
|
||||
customerData: UpdateCustomerData
|
||||
): Promise<{ success: boolean; customer?: Customer; error?: string }> {
|
||||
try {
|
||||
// Check if customer exists
|
||||
const existingCustomer = await prisma.customer.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingCustomer) {
|
||||
return { success: false, error: "العميل غير موجود" };
|
||||
}
|
||||
|
||||
// Check for phone/email conflicts with other customers
|
||||
if (customerData.phone || customerData.email) {
|
||||
const conflictCustomer = await prisma.customer.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{ id: { not: id } },
|
||||
{
|
||||
OR: [
|
||||
customerData.phone ? { phone: customerData.phone } : {},
|
||||
customerData.email ? { email: customerData.email } : {},
|
||||
].filter(condition => Object.keys(condition).length > 0),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (conflictCustomer) {
|
||||
if (conflictCustomer.phone === customerData.phone) {
|
||||
return { success: false, error: "رقم الهاتف موجود بالفعل" };
|
||||
}
|
||||
if (conflictCustomer.email === customerData.email) {
|
||||
return { success: false, error: "البريد الإلكتروني موجود بالفعل" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: any = {};
|
||||
if (customerData.name !== undefined) updateData.name = customerData.name.trim();
|
||||
if (customerData.phone !== undefined) updateData.phone = customerData.phone?.trim() || null;
|
||||
if (customerData.email !== undefined) updateData.email = customerData.email?.trim() || null;
|
||||
if (customerData.address !== undefined) updateData.address = customerData.address?.trim() || null;
|
||||
|
||||
// Update customer
|
||||
const customer = await prisma.customer.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return { success: true, customer };
|
||||
} catch (error) {
|
||||
console.error("Error updating customer:", error);
|
||||
return { success: false, error: "حدث خطأ أثناء تحديث العميل" };
|
||||
}
|
||||
}
|
||||
|
||||
// Delete customer with relationship handling
|
||||
export async function deleteCustomer(
|
||||
id: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Check if customer exists
|
||||
const existingCustomer = await prisma.customer.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
vehicles: true,
|
||||
maintenanceVisits: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingCustomer) {
|
||||
return { success: false, error: "العميل غير موجود" };
|
||||
}
|
||||
|
||||
// Check if customer has vehicles or maintenance visits
|
||||
if (existingCustomer.vehicles.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `لا يمكن حذف العميل لأنه يملك ${existingCustomer.vehicles.length} مركبة. يرجى حذف المركبات أولاً`
|
||||
};
|
||||
}
|
||||
|
||||
if (existingCustomer.maintenanceVisits.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `لا يمكن حذف العميل لأنه لديه ${existingCustomer.maintenanceVisits.length} زيارة صيانة. يرجى حذف الزيارات أولاً`
|
||||
};
|
||||
}
|
||||
|
||||
// Delete customer
|
||||
await prisma.customer.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error deleting customer:", error);
|
||||
return { success: false, error: "حدث خطأ أثناء حذف العميل" };
|
||||
}
|
||||
}
|
||||
|
||||
// Get customers for dropdown/select options
|
||||
export async function getCustomersForSelect(): Promise<{ id: number; name: string; phone?: string | null }[]> {
|
||||
return await prisma.customer.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
// Get customer statistics
|
||||
export async function getCustomerStats(customerId: number): Promise<{
|
||||
totalVehicles: number;
|
||||
totalVisits: number;
|
||||
totalSpent: number;
|
||||
lastVisitDate?: Date;
|
||||
} | null> {
|
||||
const customer = await prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
include: {
|
||||
vehicles: {
|
||||
select: { id: true },
|
||||
},
|
||||
maintenanceVisits: {
|
||||
select: {
|
||||
cost: true,
|
||||
visitDate: true,
|
||||
},
|
||||
orderBy: { visitDate: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!customer) return null;
|
||||
|
||||
const totalSpent = customer.maintenanceVisits.reduce((sum, visit) => sum + visit.cost, 0);
|
||||
const lastVisitDate = customer.maintenanceVisits.length > 0
|
||||
? customer.maintenanceVisits[0].visitDate
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
totalVehicles: customer.vehicles.length,
|
||||
totalVisits: customer.maintenanceVisits.length,
|
||||
totalSpent,
|
||||
lastVisitDate,
|
||||
};
|
||||
}
|
||||
|
||||
// Search customers by name or phone (for autocomplete)
|
||||
export async function searchCustomers(query: string, limit: number = 10): Promise<{
|
||||
id: number;
|
||||
name: string;
|
||||
phone?: string | null;
|
||||
email?: string | null;
|
||||
}[]> {
|
||||
if (!query || query.trim().length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchLower = query.toLowerCase();
|
||||
|
||||
return await prisma.customer.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: { contains: searchLower } },
|
||||
{ phone: { contains: searchLower } },
|
||||
{ email: { contains: searchLower } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
405
app/lib/db.server.ts
Normal file
405
app/lib/db.server.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
let prisma: PrismaClient;
|
||||
|
||||
declare global {
|
||||
var __db__: PrismaClient;
|
||||
}
|
||||
|
||||
// This is needed because in development we don't want to restart
|
||||
// the server with every change, but we want to make sure we don't
|
||||
// create a new connection to the DB with every change either.
|
||||
// In production, we'll have a single connection to the DB.
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
prisma = new PrismaClient();
|
||||
} else {
|
||||
if (!global.__db__) {
|
||||
global.__db__ = new PrismaClient();
|
||||
}
|
||||
prisma = global.__db__;
|
||||
prisma.$connect();
|
||||
}
|
||||
|
||||
export { prisma };
|
||||
|
||||
// Database utility functions
|
||||
export async function createUser(data: {
|
||||
name: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
authLevel: number;
|
||||
status?: string;
|
||||
}) {
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
...data,
|
||||
status: data.status || 'active',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserByUsername(username: string) {
|
||||
return prisma.user.findUnique({
|
||||
where: { username },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
return prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserById(id: number) {
|
||||
return prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
status: true,
|
||||
authLevel: true,
|
||||
createdDate: true,
|
||||
editDate: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateUser(id: number, data: {
|
||||
name?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
authLevel?: number;
|
||||
status?: string;
|
||||
}) {
|
||||
return prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUser(id: number) {
|
||||
return prisma.user.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUsers(authLevel?: number) {
|
||||
const where = authLevel ? { authLevel: { gte: authLevel } } : {};
|
||||
|
||||
return prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
status: true,
|
||||
authLevel: true,
|
||||
createdDate: true,
|
||||
editDate: true,
|
||||
},
|
||||
orderBy: { createdDate: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
// Customer operations
|
||||
export async function createCustomer(data: {
|
||||
name: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
}) {
|
||||
return prisma.customer.create({ data });
|
||||
}
|
||||
|
||||
export async function getCustomers() {
|
||||
return prisma.customer.findMany({
|
||||
include: {
|
||||
vehicles: true,
|
||||
_count: {
|
||||
select: {
|
||||
vehicles: true,
|
||||
maintenanceVisits: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdDate: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCustomerById(id: number) {
|
||||
return prisma.customer.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
vehicles: true,
|
||||
maintenanceVisits: {
|
||||
include: {
|
||||
vehicle: true,
|
||||
},
|
||||
orderBy: { visitDate: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateCustomer(id: number, data: {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
}) {
|
||||
return prisma.customer.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteCustomer(id: number) {
|
||||
return prisma.customer.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// Vehicle operations
|
||||
export async function createVehicle(data: {
|
||||
plateNumber: string;
|
||||
bodyType: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
trim?: string;
|
||||
year: number;
|
||||
transmission: string;
|
||||
fuel: string;
|
||||
cylinders?: number;
|
||||
engineDisplacement?: number;
|
||||
useType: string;
|
||||
ownerId: number;
|
||||
}) {
|
||||
return prisma.vehicle.create({ data });
|
||||
}
|
||||
|
||||
export async function getVehicles() {
|
||||
return prisma.vehicle.findMany({
|
||||
include: {
|
||||
owner: true,
|
||||
_count: {
|
||||
select: {
|
||||
maintenanceVisits: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdDate: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getVehicleById(id: number) {
|
||||
return prisma.vehicle.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
owner: true,
|
||||
maintenanceVisits: {
|
||||
include: {
|
||||
income: true,
|
||||
},
|
||||
orderBy: { visitDate: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateVehicle(id: number, data: {
|
||||
plateNumber?: string;
|
||||
bodyType?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
trim?: string;
|
||||
year?: number;
|
||||
transmission?: string;
|
||||
fuel?: string;
|
||||
cylinders?: number;
|
||||
engineDisplacement?: number;
|
||||
useType?: string;
|
||||
ownerId?: number;
|
||||
lastVisitDate?: Date;
|
||||
suggestedNextVisitDate?: Date;
|
||||
}) {
|
||||
return prisma.vehicle.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteVehicle(id: number) {
|
||||
return prisma.vehicle.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// Maintenance visit operations
|
||||
export async function createMaintenanceVisit(data: {
|
||||
vehicleId: number;
|
||||
customerId: number;
|
||||
maintenanceType: string;
|
||||
description: string;
|
||||
cost: number;
|
||||
paymentStatus?: string;
|
||||
kilometers: number;
|
||||
visitDate?: Date;
|
||||
nextVisitDelay: number;
|
||||
}) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// Create the maintenance visit
|
||||
const visit = await tx.maintenanceVisit.create({
|
||||
data: {
|
||||
...data,
|
||||
paymentStatus: data.paymentStatus || 'pending',
|
||||
visitDate: data.visitDate || new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Update vehicle's last visit date and calculate next visit date
|
||||
const nextVisitDate = new Date();
|
||||
nextVisitDate.setMonth(nextVisitDate.getMonth() + data.nextVisitDelay);
|
||||
|
||||
await tx.vehicle.update({
|
||||
where: { id: data.vehicleId },
|
||||
data: {
|
||||
lastVisitDate: visit.visitDate,
|
||||
suggestedNextVisitDate: nextVisitDate,
|
||||
},
|
||||
});
|
||||
|
||||
// Create corresponding income record
|
||||
await tx.income.create({
|
||||
data: {
|
||||
maintenanceVisitId: visit.id,
|
||||
amount: data.cost,
|
||||
incomeDate: visit.visitDate,
|
||||
},
|
||||
});
|
||||
|
||||
return visit;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMaintenanceVisits() {
|
||||
return prisma.maintenanceVisit.findMany({
|
||||
include: {
|
||||
vehicle: {
|
||||
include: {
|
||||
owner: true,
|
||||
},
|
||||
},
|
||||
customer: true,
|
||||
income: true,
|
||||
},
|
||||
orderBy: { visitDate: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMaintenanceVisitById(id: number) {
|
||||
return prisma.maintenanceVisit.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
vehicle: {
|
||||
include: {
|
||||
owner: true,
|
||||
},
|
||||
},
|
||||
customer: true,
|
||||
income: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateMaintenanceVisit(id: number, data: {
|
||||
vehicleId?: number;
|
||||
customerId?: number;
|
||||
maintenanceType?: string;
|
||||
description?: string;
|
||||
cost?: number;
|
||||
paymentStatus?: string;
|
||||
kilometers?: number;
|
||||
visitDate?: Date;
|
||||
nextVisitDelay?: number;
|
||||
}) {
|
||||
return prisma.maintenanceVisit.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMaintenanceVisit(id: number) {
|
||||
return prisma.maintenanceVisit.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// Financial operations
|
||||
export async function createExpense(data: {
|
||||
description: string;
|
||||
category: string;
|
||||
amount: number;
|
||||
expenseDate?: Date;
|
||||
}) {
|
||||
return prisma.expense.create({
|
||||
data: {
|
||||
...data,
|
||||
expenseDate: data.expenseDate || new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getExpenses() {
|
||||
return prisma.expense.findMany({
|
||||
orderBy: { expenseDate: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getIncome() {
|
||||
return prisma.income.findMany({
|
||||
include: {
|
||||
maintenanceVisit: {
|
||||
include: {
|
||||
vehicle: true,
|
||||
customer: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { incomeDate: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFinancialSummary(dateFrom?: Date, dateTo?: Date) {
|
||||
const where = dateFrom && dateTo ? {
|
||||
AND: [
|
||||
{ createdDate: { gte: dateFrom } },
|
||||
{ createdDate: { lte: dateTo } },
|
||||
],
|
||||
} : {};
|
||||
|
||||
const [totalIncome, totalExpenses] = await Promise.all([
|
||||
prisma.income.aggregate({
|
||||
where: dateFrom && dateTo ? {
|
||||
incomeDate: { gte: dateFrom, lte: dateTo },
|
||||
} : {},
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
prisma.expense.aggregate({
|
||||
where: dateFrom && dateTo ? {
|
||||
expenseDate: { gte: dateFrom, lte: dateTo },
|
||||
} : {},
|
||||
_sum: { amount: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalIncome: totalIncome._sum.amount || 0,
|
||||
totalExpenses: totalExpenses._sum.amount || 0,
|
||||
netProfit: (totalIncome._sum.amount || 0) - (totalExpenses._sum.amount || 0),
|
||||
};
|
||||
}
|
||||
176
app/lib/expense-management.server.ts
Normal file
176
app/lib/expense-management.server.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { prisma } from "./db.server";
|
||||
import type { Expense } from "@prisma/client";
|
||||
import type { CreateExpenseData, UpdateExpenseData, FinancialSearchParams } from "~/types/database";
|
||||
|
||||
// Get all expenses with search and pagination
|
||||
export async function getExpenses(
|
||||
searchQuery?: string,
|
||||
page: number = 1,
|
||||
limit: number = 10,
|
||||
category?: string,
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date
|
||||
): Promise<{
|
||||
expenses: Expense[];
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build where clause for search and filters
|
||||
const whereClause: any = {};
|
||||
|
||||
if (category) {
|
||||
whereClause.category = category;
|
||||
}
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
whereClause.expenseDate = {};
|
||||
if (dateFrom) {
|
||||
whereClause.expenseDate.gte = dateFrom;
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClause.expenseDate.lte = dateTo;
|
||||
}
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
whereClause.OR = [
|
||||
{ description: { contains: searchLower } },
|
||||
{ category: { contains: searchLower } },
|
||||
];
|
||||
}
|
||||
|
||||
const [expenses, total] = await Promise.all([
|
||||
prisma.expense.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { expenseDate: 'desc' },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.expense.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
expenses,
|
||||
total,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
// Get a single expense by ID
|
||||
export async function getExpenseById(id: number): Promise<Expense | null> {
|
||||
return prisma.expense.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new expense
|
||||
export async function createExpense(data: CreateExpenseData): Promise<Expense> {
|
||||
return prisma.expense.create({
|
||||
data: {
|
||||
description: data.description,
|
||||
category: data.category,
|
||||
amount: data.amount,
|
||||
expenseDate: data.expenseDate || new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update an existing expense
|
||||
export async function updateExpense(id: number, data: UpdateExpenseData): Promise<Expense> {
|
||||
return prisma.expense.update({
|
||||
where: { id },
|
||||
data: {
|
||||
description: data.description,
|
||||
category: data.category,
|
||||
amount: data.amount,
|
||||
expenseDate: data.expenseDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Delete an expense
|
||||
export async function deleteExpense(id: number): Promise<void> {
|
||||
await prisma.expense.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// Get expense categories for dropdown
|
||||
export async function getExpenseCategories(): Promise<string[]> {
|
||||
const categories = await prisma.expense.findMany({
|
||||
select: { category: true },
|
||||
distinct: ['category'],
|
||||
orderBy: { category: 'asc' },
|
||||
});
|
||||
|
||||
return categories.map(c => c.category);
|
||||
}
|
||||
|
||||
// Get expenses by category for reporting
|
||||
export async function getExpensesByCategory(
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date
|
||||
): Promise<{ category: string; total: number; count: number }[]> {
|
||||
const whereClause: any = {};
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
whereClause.expenseDate = {};
|
||||
if (dateFrom) {
|
||||
whereClause.expenseDate.gte = dateFrom;
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClause.expenseDate.lte = dateTo;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await prisma.expense.groupBy({
|
||||
by: ['category'],
|
||||
where: whereClause,
|
||||
_sum: {
|
||||
amount: true,
|
||||
},
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
orderBy: {
|
||||
_sum: {
|
||||
amount: 'desc',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return result.map(item => ({
|
||||
category: item.category,
|
||||
total: item._sum.amount || 0,
|
||||
count: item._count.id,
|
||||
}));
|
||||
}
|
||||
|
||||
// Get total expenses for a date range
|
||||
export async function getTotalExpenses(dateFrom?: Date, dateTo?: Date): Promise<number> {
|
||||
const whereClause: any = {};
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
whereClause.expenseDate = {};
|
||||
if (dateFrom) {
|
||||
whereClause.expenseDate.gte = dateFrom;
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClause.expenseDate.lte = dateTo;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await prisma.expense.aggregate({
|
||||
where: whereClause,
|
||||
_sum: {
|
||||
amount: true,
|
||||
},
|
||||
});
|
||||
|
||||
return result._sum.amount || 0;
|
||||
}
|
||||
361
app/lib/financial-reporting.server.ts
Normal file
361
app/lib/financial-reporting.server.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
import { prisma } from "./db.server";
|
||||
import { getTotalExpenses, getExpensesByCategory } from "./expense-management.server";
|
||||
|
||||
// Financial summary interface
|
||||
export interface FinancialSummary {
|
||||
totalIncome: number;
|
||||
totalExpenses: number;
|
||||
netProfit: number;
|
||||
incomeCount: number;
|
||||
expenseCount: number;
|
||||
profitMargin: number;
|
||||
}
|
||||
|
||||
// Monthly financial data interface
|
||||
export interface MonthlyFinancialData {
|
||||
month: string;
|
||||
year: number;
|
||||
income: number;
|
||||
expenses: number;
|
||||
profit: number;
|
||||
}
|
||||
|
||||
// Category breakdown interface
|
||||
export interface CategoryBreakdown {
|
||||
category: string;
|
||||
amount: number;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
// Get financial summary for a date range
|
||||
export async function getFinancialSummary(
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date
|
||||
): Promise<FinancialSummary> {
|
||||
const whereClause: any = {};
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
whereClause.incomeDate = {};
|
||||
if (dateFrom) {
|
||||
whereClause.incomeDate.gte = dateFrom;
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClause.incomeDate.lte = dateTo;
|
||||
}
|
||||
}
|
||||
|
||||
// Get income data
|
||||
const incomeResult = await prisma.income.aggregate({
|
||||
where: whereClause,
|
||||
_sum: {
|
||||
amount: true,
|
||||
},
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const totalIncome = incomeResult._sum.amount || 0;
|
||||
const incomeCount = incomeResult._count.id;
|
||||
|
||||
// Get expense data
|
||||
const totalExpenses = await getTotalExpenses(dateFrom, dateTo);
|
||||
const expenseCount = await prisma.expense.count({
|
||||
where: dateFrom || dateTo ? {
|
||||
expenseDate: {
|
||||
...(dateFrom && { gte: dateFrom }),
|
||||
...(dateTo && { lte: dateTo }),
|
||||
},
|
||||
} : undefined,
|
||||
});
|
||||
|
||||
// Calculate derived metrics
|
||||
const netProfit = totalIncome - totalExpenses;
|
||||
const profitMargin = totalIncome > 0 ? (netProfit / totalIncome) * 100 : 0;
|
||||
|
||||
return {
|
||||
totalIncome,
|
||||
totalExpenses,
|
||||
netProfit,
|
||||
incomeCount,
|
||||
expenseCount,
|
||||
profitMargin,
|
||||
};
|
||||
}
|
||||
|
||||
// Get monthly financial data for the last 12 months
|
||||
export async function getMonthlyFinancialData(): Promise<MonthlyFinancialData[]> {
|
||||
const months: MonthlyFinancialData[] = [];
|
||||
const currentDate = new Date();
|
||||
|
||||
for (let i = 11; i >= 0; i--) {
|
||||
const date = new Date(currentDate.getFullYear(), currentDate.getMonth() - i, 1);
|
||||
const nextMonth = new Date(date.getFullYear(), date.getMonth() + 1, 1);
|
||||
|
||||
const monthNumber = date.getMonth() + 1; // 1-12
|
||||
const year = date.getFullYear();
|
||||
|
||||
// Get income for this month
|
||||
const incomeResult = await prisma.income.aggregate({
|
||||
where: {
|
||||
incomeDate: {
|
||||
gte: date,
|
||||
lt: nextMonth,
|
||||
},
|
||||
},
|
||||
_sum: {
|
||||
amount: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Get expenses for this month
|
||||
const expenseResult = await prisma.expense.aggregate({
|
||||
where: {
|
||||
expenseDate: {
|
||||
gte: date,
|
||||
lt: nextMonth,
|
||||
},
|
||||
},
|
||||
_sum: {
|
||||
amount: true,
|
||||
},
|
||||
});
|
||||
|
||||
const income = incomeResult._sum.amount || 0;
|
||||
const expenses = expenseResult._sum.amount || 0;
|
||||
const profit = income - expenses;
|
||||
|
||||
months.push({
|
||||
month: monthNumber.toString(),
|
||||
year,
|
||||
income,
|
||||
expenses,
|
||||
profit,
|
||||
});
|
||||
}
|
||||
|
||||
return months;
|
||||
}
|
||||
|
||||
// Get income breakdown by maintenance type
|
||||
export async function getIncomeByMaintenanceType(
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date
|
||||
): Promise<CategoryBreakdown[]> {
|
||||
const whereClause: any = {};
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
whereClause.incomeDate = {};
|
||||
if (dateFrom) {
|
||||
whereClause.incomeDate.gte = dateFrom;
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClause.incomeDate.lte = dateTo;
|
||||
}
|
||||
}
|
||||
|
||||
// Get income grouped by maintenance type
|
||||
const result = await prisma.income.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
maintenanceVisit: {
|
||||
select: {
|
||||
maintenanceJobs: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Group by maintenance type
|
||||
const grouped = result.reduce((acc, income) => {
|
||||
try {
|
||||
const jobs = JSON.parse(income.maintenanceVisit.maintenanceJobs);
|
||||
|
||||
// Calculate total cost from individual jobs to determine weights
|
||||
const totalJobCost = jobs.reduce((sum: number, job: any) => sum + (job.cost || 0), 0);
|
||||
|
||||
if (totalJobCost > 0) {
|
||||
// Weighted distribution based on specific costs
|
||||
jobs.forEach((job: any) => {
|
||||
const type = job.job || 'غير محدد';
|
||||
const jobCost = job.cost || 0;
|
||||
// Calculate weight: if total cost is 0 (shouldn't happen due to if check), avoid division by zero
|
||||
const weight = jobCost / totalJobCost;
|
||||
const attributedAmount = income.amount * weight;
|
||||
|
||||
if (!acc[type]) {
|
||||
acc[type] = { amount: 0, count: 0 };
|
||||
}
|
||||
acc[type].amount += attributedAmount;
|
||||
acc[type].count += 1;
|
||||
});
|
||||
} else {
|
||||
// Fallback: Equal distribution (Legacy behavior)
|
||||
const types = jobs.map((job: any) => job.job || 'غير محدد');
|
||||
types.forEach((type: string) => {
|
||||
if (!acc[type]) {
|
||||
acc[type] = { amount: 0, count: 0 };
|
||||
}
|
||||
acc[type].amount += income.amount / types.length;
|
||||
acc[type].count += 1;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Fallback for invalid JSON
|
||||
const type = 'غير محدد';
|
||||
if (!acc[type]) {
|
||||
acc[type] = { amount: 0, count: 0 };
|
||||
}
|
||||
acc[type].amount += income.amount;
|
||||
acc[type].count += 1;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, { amount: number; count: number }>);
|
||||
|
||||
// Calculate total for percentage calculation
|
||||
const total = Object.values(grouped).reduce((sum, item) => sum + item.amount, 0);
|
||||
|
||||
// Convert to array with percentages
|
||||
return Object.entries(grouped)
|
||||
.map(([category, data]) => ({
|
||||
category,
|
||||
amount: data.amount,
|
||||
count: data.count,
|
||||
percentage: total > 0 ? (data.amount / total) * 100 : 0,
|
||||
}))
|
||||
.sort((a, b) => b.amount - a.amount);
|
||||
}
|
||||
|
||||
// Get expense breakdown by category
|
||||
export async function getExpenseBreakdown(
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date
|
||||
): Promise<CategoryBreakdown[]> {
|
||||
const categoryData = await getExpensesByCategory(dateFrom, dateTo);
|
||||
const total = categoryData.reduce((sum, item) => sum + item.total, 0);
|
||||
|
||||
return categoryData.map(item => ({
|
||||
category: item.category,
|
||||
amount: item.total,
|
||||
count: item.count,
|
||||
percentage: total > 0 ? (item.total / total) * 100 : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// Get top customers by revenue
|
||||
export async function getTopCustomersByRevenue(
|
||||
limit: number = 10,
|
||||
dateFrom?: Date,
|
||||
dateTo?: Date
|
||||
): Promise<{
|
||||
customerId: number;
|
||||
customerName: string;
|
||||
totalRevenue: number;
|
||||
visitCount: number;
|
||||
}[]> {
|
||||
const whereClause: any = {};
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
whereClause.incomeDate = {};
|
||||
if (dateFrom) {
|
||||
whereClause.incomeDate.gte = dateFrom;
|
||||
}
|
||||
if (dateTo) {
|
||||
whereClause.incomeDate.lte = dateTo;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await prisma.income.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
maintenanceVisit: {
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Group by customer
|
||||
const customerRevenue = result.reduce((acc, income) => {
|
||||
const customer = income.maintenanceVisit.customer;
|
||||
const customerId = customer.id;
|
||||
|
||||
if (!acc[customerId]) {
|
||||
acc[customerId] = {
|
||||
customerId,
|
||||
customerName: customer.name,
|
||||
totalRevenue: 0,
|
||||
visitCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
acc[customerId].totalRevenue += income.amount;
|
||||
acc[customerId].visitCount += 1;
|
||||
|
||||
return acc;
|
||||
}, {} as Record<number, {
|
||||
customerId: number;
|
||||
customerName: string;
|
||||
totalRevenue: number;
|
||||
visitCount: number;
|
||||
}>);
|
||||
|
||||
return Object.values(customerRevenue)
|
||||
.sort((a, b) => b.totalRevenue - a.totalRevenue)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
// Get financial trends (comparing current period with previous period)
|
||||
export async function getFinancialTrends(
|
||||
dateFrom: Date,
|
||||
dateTo: Date
|
||||
): Promise<{
|
||||
currentPeriod: FinancialSummary;
|
||||
previousPeriod: FinancialSummary;
|
||||
trends: {
|
||||
incomeGrowth: number;
|
||||
expenseGrowth: number;
|
||||
profitGrowth: number;
|
||||
};
|
||||
}> {
|
||||
// Calculate previous period dates
|
||||
const periodLength = dateTo.getTime() - dateFrom.getTime();
|
||||
const previousDateTo = new Date(dateFrom.getTime() - 1);
|
||||
const previousDateFrom = new Date(previousDateTo.getTime() - periodLength);
|
||||
|
||||
// Get current and previous period summaries
|
||||
const [currentPeriod, previousPeriod] = await Promise.all([
|
||||
getFinancialSummary(dateFrom, dateTo),
|
||||
getFinancialSummary(previousDateFrom, previousDateTo),
|
||||
]);
|
||||
|
||||
// Calculate growth percentages
|
||||
const incomeGrowth = previousPeriod.totalIncome > 0
|
||||
? ((currentPeriod.totalIncome - previousPeriod.totalIncome) / previousPeriod.totalIncome) * 100
|
||||
: 0;
|
||||
|
||||
const expenseGrowth = previousPeriod.totalExpenses > 0
|
||||
? ((currentPeriod.totalExpenses - previousPeriod.totalExpenses) / previousPeriod.totalExpenses) * 100
|
||||
: 0;
|
||||
|
||||
const profitGrowth = previousPeriod.netProfit !== 0
|
||||
? ((currentPeriod.netProfit - previousPeriod.netProfit) / Math.abs(previousPeriod.netProfit)) * 100
|
||||
: 0;
|
||||
|
||||
return {
|
||||
currentPeriod,
|
||||
previousPeriod,
|
||||
trends: {
|
||||
incomeGrowth,
|
||||
expenseGrowth,
|
||||
profitGrowth,
|
||||
},
|
||||
};
|
||||
}
|
||||
274
app/lib/form-validation.ts
Normal file
274
app/lib/form-validation.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { z } from 'zod';
|
||||
import { VALIDATION, AUTH_LEVELS, USER_STATUS, TRANSMISSION_TYPES, FUEL_TYPES, USE_TYPES, PAYMENT_STATUS } from './constants';
|
||||
|
||||
// Zod schemas for server-side validation
|
||||
export const userSchema = z.object({
|
||||
name: z.string()
|
||||
.min(1, 'الاسم مطلوب')
|
||||
.max(VALIDATION.MAX_NAME_LENGTH, `الاسم يجب أن يكون أقل من ${VALIDATION.MAX_NAME_LENGTH} حرف`)
|
||||
.trim(),
|
||||
username: z.string()
|
||||
.min(3, 'اسم المستخدم يجب أن يكون على الأقل 3 أحرف')
|
||||
.regex(/^[a-zA-Z0-9_]+$/, 'اسم المستخدم يجب أن يحتوي على أحرف وأرقام فقط')
|
||||
.trim(),
|
||||
email: z.string()
|
||||
.email('البريد الإلكتروني غير صحيح')
|
||||
.trim(),
|
||||
password: z.string()
|
||||
.min(VALIDATION.MIN_PASSWORD_LENGTH, `كلمة المرور يجب أن تكون على الأقل ${VALIDATION.MIN_PASSWORD_LENGTH} أحرف`),
|
||||
authLevel: z.number()
|
||||
.refine(val => Object.values(AUTH_LEVELS).includes(val as any), 'مستوى الصلاحية غير صحيح'),
|
||||
status: z.enum([USER_STATUS.ACTIVE, USER_STATUS.INACTIVE], {
|
||||
errorMap: () => ({ message: 'حالة المستخدم غير صحيحة' })
|
||||
}),
|
||||
});
|
||||
|
||||
export const customerSchema = z.object({
|
||||
name: z.string()
|
||||
.min(1, 'اسم العميل مطلوب')
|
||||
.max(VALIDATION.MAX_NAME_LENGTH, `الاسم يجب أن يكون أقل من ${VALIDATION.MAX_NAME_LENGTH} حرف`)
|
||||
.trim(),
|
||||
phone: z.string()
|
||||
.regex(/^[\+]?[0-9\s\-\(\)]*$/, 'رقم الهاتف غير صحيح')
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
email: z.string()
|
||||
.email('البريد الإلكتروني غير صحيح')
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
address: z.string()
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
});
|
||||
|
||||
export const vehicleSchema = z.object({
|
||||
plateNumber: z.string()
|
||||
.min(1, 'رقم اللوحة مطلوب')
|
||||
.trim(),
|
||||
bodyType: z.string()
|
||||
.min(1, 'نوع الهيكل مطلوب')
|
||||
.trim(),
|
||||
manufacturer: z.string()
|
||||
.min(1, 'الشركة المصنعة مطلوبة')
|
||||
.trim(),
|
||||
model: z.string()
|
||||
.min(1, 'الموديل مطلوب')
|
||||
.trim(),
|
||||
trim: z.string()
|
||||
.optional()
|
||||
.or(z.literal('')),
|
||||
year: z.number()
|
||||
.min(VALIDATION.MIN_YEAR, `السنة يجب أن تكون بين ${VALIDATION.MIN_YEAR} و ${VALIDATION.MAX_YEAR}`)
|
||||
.max(VALIDATION.MAX_YEAR, `السنة يجب أن تكون بين ${VALIDATION.MIN_YEAR} و ${VALIDATION.MAX_YEAR}`),
|
||||
transmission: z.enum(TRANSMISSION_TYPES.map(t => t.value) as [string, ...string[]], {
|
||||
errorMap: () => ({ message: 'نوع ناقل الحركة غير صحيح' })
|
||||
}),
|
||||
fuel: z.enum(FUEL_TYPES.map(f => f.value) as [string, ...string[]], {
|
||||
errorMap: () => ({ message: 'نوع الوقود غير صحيح' })
|
||||
}),
|
||||
cylinders: z.number()
|
||||
.min(1, `عدد الأسطوانات يجب أن يكون بين 1 و ${VALIDATION.MAX_CYLINDERS}`)
|
||||
.max(VALIDATION.MAX_CYLINDERS, `عدد الأسطوانات يجب أن يكون بين 1 و ${VALIDATION.MAX_CYLINDERS}`)
|
||||
.optional()
|
||||
.nullable(),
|
||||
engineDisplacement: z.number()
|
||||
.min(0.1, `سعة المحرك يجب أن تكون بين 0.1 و ${VALIDATION.MAX_ENGINE_DISPLACEMENT}`)
|
||||
.max(VALIDATION.MAX_ENGINE_DISPLACEMENT, `سعة المحرك يجب أن تكون بين 0.1 و ${VALIDATION.MAX_ENGINE_DISPLACEMENT}`)
|
||||
.optional()
|
||||
.nullable(),
|
||||
useType: z.enum(USE_TYPES.map(u => u.value) as [string, ...string[]], {
|
||||
errorMap: () => ({ message: 'نوع الاستخدام غير صحيح' })
|
||||
}),
|
||||
ownerId: z.number()
|
||||
.min(1, 'مالك المركبة مطلوب'),
|
||||
});
|
||||
|
||||
export const maintenanceVisitSchema = z.object({
|
||||
vehicleId: z.number()
|
||||
.min(1, 'المركبة مطلوبة'),
|
||||
customerId: z.number()
|
||||
.min(1, 'العميل مطلوب'),
|
||||
maintenanceType: z.string()
|
||||
.min(1, 'نوع الصيانة مطلوب')
|
||||
.trim(),
|
||||
description: z.string()
|
||||
.min(1, 'وصف الصيانة مطلوب')
|
||||
.max(VALIDATION.MAX_DESCRIPTION_LENGTH, `الوصف يجب أن يكون أقل من ${VALIDATION.MAX_DESCRIPTION_LENGTH} حرف`)
|
||||
.trim(),
|
||||
cost: z.number()
|
||||
.min(VALIDATION.MIN_COST, `التكلفة يجب أن تكون بين ${VALIDATION.MIN_COST} و ${VALIDATION.MAX_COST}`)
|
||||
.max(VALIDATION.MAX_COST, `التكلفة يجب أن تكون بين ${VALIDATION.MIN_COST} و ${VALIDATION.MAX_COST}`),
|
||||
paymentStatus: z.enum(Object.values(PAYMENT_STATUS) as [string, ...string[]], {
|
||||
errorMap: () => ({ message: 'حالة الدفع غير صحيحة' })
|
||||
}),
|
||||
kilometers: z.number()
|
||||
.min(0, 'عدد الكيلومترات يجب أن يكون رقم موجب'),
|
||||
nextVisitDelay: z.number()
|
||||
.refine(val => [1, 2, 3, 4].includes(val), 'فترة الزيارة التالية يجب أن تكون 1، 2، 3، أو 4 أشهر'),
|
||||
});
|
||||
|
||||
export const expenseSchema = z.object({
|
||||
description: z.string()
|
||||
.min(1, 'وصف المصروف مطلوب')
|
||||
.max(VALIDATION.MAX_DESCRIPTION_LENGTH, `الوصف يجب أن يكون أقل من ${VALIDATION.MAX_DESCRIPTION_LENGTH} حرف`)
|
||||
.trim(),
|
||||
category: z.string()
|
||||
.min(1, 'فئة المصروف مطلوبة')
|
||||
.trim(),
|
||||
amount: z.number()
|
||||
.min(VALIDATION.MIN_COST + 0.01, `المبلغ يجب أن يكون بين ${VALIDATION.MIN_COST + 0.01} و ${VALIDATION.MAX_COST}`)
|
||||
.max(VALIDATION.MAX_COST, `المبلغ يجب أن يكون بين ${VALIDATION.MIN_COST + 0.01} و ${VALIDATION.MAX_COST}`),
|
||||
});
|
||||
|
||||
// Validation result type
|
||||
export interface ValidationResult {
|
||||
success: boolean;
|
||||
errors: Record<string, string>;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
// Server-side validation functions
|
||||
export function validateUserData(data: any): ValidationResult {
|
||||
try {
|
||||
const validatedData = userSchema.parse(data);
|
||||
return { success: true, errors: {}, data: validatedData };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const errors: Record<string, string> = {};
|
||||
error.issues.forEach((issue) => {
|
||||
if (issue.path.length > 0) {
|
||||
errors[issue.path[0] as string] = issue.message;
|
||||
}
|
||||
});
|
||||
return { success: false, errors };
|
||||
}
|
||||
return { success: false, errors: { general: 'خطأ في التحقق من البيانات' } };
|
||||
}
|
||||
}
|
||||
|
||||
export function validateCustomerData(data: any): ValidationResult {
|
||||
try {
|
||||
const validatedData = customerSchema.parse(data);
|
||||
return { success: true, errors: {}, data: validatedData };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const errors: Record<string, string> = {};
|
||||
error.issues.forEach((issue) => {
|
||||
if (issue.path.length > 0) {
|
||||
errors[issue.path[0] as string] = issue.message;
|
||||
}
|
||||
});
|
||||
return { success: false, errors };
|
||||
}
|
||||
return { success: false, errors: { general: 'خطأ في التحقق من البيانات' } };
|
||||
}
|
||||
}
|
||||
|
||||
export function validateVehicleData(data: any): ValidationResult {
|
||||
try {
|
||||
// Convert string numbers to actual numbers
|
||||
const processedData = {
|
||||
...data,
|
||||
year: data.year ? parseInt(data.year) : undefined,
|
||||
cylinders: data.cylinders ? parseInt(data.cylinders) : null,
|
||||
engineDisplacement: data.engineDisplacement ? parseFloat(data.engineDisplacement) : null,
|
||||
ownerId: data.ownerId ? parseInt(data.ownerId) : undefined,
|
||||
};
|
||||
|
||||
const validatedData = vehicleSchema.parse(processedData);
|
||||
return { success: true, errors: {}, data: validatedData };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const errors: Record<string, string> = {};
|
||||
error.issues.forEach((issue) => {
|
||||
if (issue.path.length > 0) {
|
||||
errors[issue.path[0] as string] = issue.message;
|
||||
}
|
||||
});
|
||||
return { success: false, errors };
|
||||
}
|
||||
return { success: false, errors: { general: 'خطأ في التحقق من البيانات' } };
|
||||
}
|
||||
}
|
||||
|
||||
export function validateMaintenanceVisitData(data: any): ValidationResult {
|
||||
try {
|
||||
// Convert string numbers to actual numbers
|
||||
const processedData = {
|
||||
...data,
|
||||
vehicleId: data.vehicleId ? parseInt(data.vehicleId) : undefined,
|
||||
customerId: data.customerId ? parseInt(data.customerId) : undefined,
|
||||
cost: data.cost ? parseFloat(data.cost) : undefined,
|
||||
kilometers: data.kilometers ? parseInt(data.kilometers) : undefined,
|
||||
nextVisitDelay: data.nextVisitDelay ? parseInt(data.nextVisitDelay) : undefined,
|
||||
};
|
||||
|
||||
const validatedData = maintenanceVisitSchema.parse(processedData);
|
||||
return { success: true, errors: {}, data: validatedData };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const errors: Record<string, string> = {};
|
||||
error.issues.forEach((issue) => {
|
||||
if (issue.path.length > 0) {
|
||||
errors[issue.path[0] as string] = issue.message;
|
||||
}
|
||||
});
|
||||
return { success: false, errors };
|
||||
}
|
||||
return { success: false, errors: { general: 'خطأ في التحقق من البيانات' } };
|
||||
}
|
||||
}
|
||||
|
||||
export function validateExpenseData(data: any): ValidationResult {
|
||||
try {
|
||||
// Convert string numbers to actual numbers
|
||||
const processedData = {
|
||||
...data,
|
||||
amount: data.amount ? parseFloat(data.amount) : undefined,
|
||||
};
|
||||
|
||||
const validatedData = expenseSchema.parse(processedData);
|
||||
return { success: true, errors: {}, data: validatedData };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const errors: Record<string, string> = {};
|
||||
error.issues.forEach((issue) => {
|
||||
if (issue.path.length > 0) {
|
||||
errors[issue.path[0] as string] = issue.message;
|
||||
}
|
||||
});
|
||||
return { success: false, errors };
|
||||
}
|
||||
return { success: false, errors: { general: 'خطأ في التحقق من البيانات' } };
|
||||
}
|
||||
}
|
||||
|
||||
// Client-side validation helpers
|
||||
export function validateField(value: any, schema: z.ZodSchema, fieldName: string): string | null {
|
||||
try {
|
||||
schema.parse({ [fieldName]: value });
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
const fieldError = error.errors.find(err => err.path.includes(fieldName));
|
||||
return fieldError?.message || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Real-time validation for forms
|
||||
export function validateFormField(fieldName: string, value: any, schema: z.ZodSchema): string | null {
|
||||
try {
|
||||
const fieldSchema = (schema as any).shape[fieldName];
|
||||
if (fieldSchema) {
|
||||
fieldSchema.parse(value);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return error.issues[0]?.message || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
146
app/lib/layout-utils.ts
Normal file
146
app/lib/layout-utils.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Layout utilities for RTL support and responsive design
|
||||
*/
|
||||
|
||||
export interface LayoutConfig {
|
||||
direction: 'rtl' | 'ltr';
|
||||
language: 'ar' | 'en';
|
||||
sidebarCollapsed: boolean;
|
||||
isMobile: boolean;
|
||||
}
|
||||
|
||||
export const defaultLayoutConfig: LayoutConfig = {
|
||||
direction: 'rtl',
|
||||
language: 'ar',
|
||||
sidebarCollapsed: false,
|
||||
isMobile: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get responsive classes based on screen size and RTL direction
|
||||
*/
|
||||
export function getResponsiveClasses(config: LayoutConfig) {
|
||||
const { direction, sidebarCollapsed, isMobile } = config;
|
||||
|
||||
return {
|
||||
container: `container-rtl ${direction === 'rtl' ? 'rtl' : 'ltr'}`,
|
||||
flexRow: direction === 'rtl' ? 'flex-rtl-reverse' : 'flex-rtl',
|
||||
textAlign: direction === 'rtl' ? 'text-right' : 'text-left',
|
||||
marginStart: direction === 'rtl' ? 'mr-auto' : 'ml-auto',
|
||||
marginEnd: direction === 'rtl' ? 'ml-auto' : 'mr-auto',
|
||||
paddingStart: direction === 'rtl' ? 'pr-4' : 'pl-4',
|
||||
paddingEnd: direction === 'rtl' ? 'pl-4' : 'pr-4',
|
||||
borderStart: direction === 'rtl' ? 'border-r' : 'border-l',
|
||||
borderEnd: direction === 'rtl' ? 'border-l' : 'border-r',
|
||||
roundedStart: direction === 'rtl' ? 'rounded-r' : 'rounded-l',
|
||||
roundedEnd: direction === 'rtl' ? 'rounded-l' : 'rounded-r',
|
||||
sidebar: getSidebarClasses(config),
|
||||
mainContent: getMainContentClasses(config),
|
||||
};
|
||||
}
|
||||
|
||||
function getSidebarClasses(config: LayoutConfig) {
|
||||
const { direction, sidebarCollapsed, isMobile } = config;
|
||||
|
||||
let classes = 'sidebar-rtl';
|
||||
|
||||
if (isMobile) {
|
||||
classes += ' mobile-sidebar-rtl';
|
||||
if (sidebarCollapsed) {
|
||||
classes += ' closed';
|
||||
}
|
||||
} else {
|
||||
if (sidebarCollapsed) {
|
||||
classes += ' collapsed';
|
||||
}
|
||||
}
|
||||
|
||||
return classes;
|
||||
}
|
||||
|
||||
function getMainContentClasses(config: LayoutConfig) {
|
||||
const { sidebarCollapsed, isMobile } = config;
|
||||
|
||||
let classes = 'main-content-rtl';
|
||||
|
||||
if (!isMobile && !sidebarCollapsed) {
|
||||
classes += ' sidebar-open';
|
||||
}
|
||||
|
||||
return classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Arabic text rendering classes
|
||||
*/
|
||||
export function getArabicTextClasses(size: 'sm' | 'base' | 'lg' | 'xl' | '2xl' = 'base') {
|
||||
const sizeClasses = {
|
||||
sm: 'text-sm',
|
||||
base: 'text-base',
|
||||
lg: 'text-lg',
|
||||
xl: 'text-xl',
|
||||
'2xl': 'text-2xl',
|
||||
};
|
||||
|
||||
return `arabic-text font-arabic ${sizeClasses[size]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get form input classes with RTL support
|
||||
*/
|
||||
export function getFormInputClasses(error?: boolean) {
|
||||
let classes = 'w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500';
|
||||
|
||||
if (error) {
|
||||
classes += ' border-red-500 focus:ring-red-500 focus:border-red-500';
|
||||
} else {
|
||||
classes += ' border-gray-300';
|
||||
}
|
||||
|
||||
return classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get button classes with RTL support
|
||||
*/
|
||||
export function getButtonClasses(
|
||||
variant: 'primary' | 'secondary' | 'danger' = 'primary',
|
||||
size: 'sm' | 'md' | 'lg' = 'md'
|
||||
) {
|
||||
const baseClasses = 'btn inline-flex items-center justify-center font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500',
|
||||
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-900 focus:ring-gray-500',
|
||||
danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-2 text-sm',
|
||||
md: 'px-4 py-2 text-base',
|
||||
lg: 'px-6 py-3 text-lg',
|
||||
};
|
||||
|
||||
return `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Breakpoint utilities for responsive design
|
||||
*/
|
||||
export const breakpoints = {
|
||||
xs: 475,
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
'2xl': 1536,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if current screen size matches breakpoint
|
||||
*/
|
||||
export function useBreakpoint(breakpoint: keyof typeof breakpoints) {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
return window.innerWidth >= breakpoints[breakpoint];
|
||||
}
|
||||
220
app/lib/maintenance-type-management.server.ts
Normal file
220
app/lib/maintenance-type-management.server.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { prisma } from "./db.server";
|
||||
import type { MaintenanceType } from "@prisma/client";
|
||||
import type { CreateMaintenanceTypeData, UpdateMaintenanceTypeData } from "~/types/database";
|
||||
|
||||
// Get all maintenance types
|
||||
export async function getMaintenanceTypes(
|
||||
includeInactive: boolean = false
|
||||
): Promise<MaintenanceType[]> {
|
||||
const whereClause = includeInactive ? {} : { isActive: true };
|
||||
|
||||
return await prisma.maintenanceType.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
// Get maintenance types for select dropdown
|
||||
export async function getMaintenanceTypesForSelect(): Promise<{
|
||||
id: number;
|
||||
name: string;
|
||||
}[]> {
|
||||
return await prisma.maintenanceType.findMany({
|
||||
where: { isActive: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
// Get maintenance type by ID
|
||||
export async function getMaintenanceTypeById(id: number): Promise<MaintenanceType | null> {
|
||||
return await prisma.maintenanceType.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// Create new maintenance type
|
||||
export async function createMaintenanceType(
|
||||
data: CreateMaintenanceTypeData
|
||||
): Promise<{ success: boolean; maintenanceType?: MaintenanceType; error?: string }> {
|
||||
try {
|
||||
// Check if maintenance type with same name already exists
|
||||
const existingType = await prisma.maintenanceType.findUnique({
|
||||
where: { name: data.name.trim() },
|
||||
});
|
||||
|
||||
if (existingType) {
|
||||
return { success: false, error: "نوع الصيانة موجود بالفعل" };
|
||||
}
|
||||
|
||||
// Create maintenance type
|
||||
const maintenanceType = await prisma.maintenanceType.create({
|
||||
data: {
|
||||
name: data.name.trim(),
|
||||
description: data.description?.trim() || null,
|
||||
isActive: data.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, maintenanceType };
|
||||
} catch (error) {
|
||||
console.error("Error creating maintenance type:", error);
|
||||
return { success: false, error: "حدث خطأ أثناء إنشاء نوع الصيانة" };
|
||||
}
|
||||
}
|
||||
|
||||
// Update maintenance type
|
||||
export async function updateMaintenanceType(
|
||||
id: number,
|
||||
data: UpdateMaintenanceTypeData
|
||||
): Promise<{ success: boolean; maintenanceType?: MaintenanceType; error?: string }> {
|
||||
try {
|
||||
// Check if maintenance type exists
|
||||
const existingType = await prisma.maintenanceType.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingType) {
|
||||
return { success: false, error: "نوع الصيانة غير موجود" };
|
||||
}
|
||||
|
||||
// Check for name conflicts with other types
|
||||
if (data.name) {
|
||||
const conflictType = await prisma.maintenanceType.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{ id: { not: id } },
|
||||
{ name: data.name.trim() },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (conflictType) {
|
||||
return { success: false, error: "اسم نوع الصيانة موجود بالفعل" };
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: any = {};
|
||||
if (data.name !== undefined) updateData.name = data.name.trim();
|
||||
if (data.description !== undefined) updateData.description = data.description?.trim() || null;
|
||||
if (data.isActive !== undefined) updateData.isActive = data.isActive;
|
||||
|
||||
// Update maintenance type
|
||||
const maintenanceType = await prisma.maintenanceType.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return { success: true, maintenanceType };
|
||||
} catch (error) {
|
||||
console.error("Error updating maintenance type:", error);
|
||||
return { success: false, error: "حدث خطأ أثناء تحديث نوع الصيانة" };
|
||||
}
|
||||
}
|
||||
|
||||
// Delete maintenance type (soft delete by setting isActive to false)
|
||||
export async function deleteMaintenanceType(
|
||||
id: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Check if maintenance type exists
|
||||
const existingType = await prisma.maintenanceType.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
maintenanceVisits: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingType) {
|
||||
return { success: false, error: "نوع الصيانة غير موجود" };
|
||||
}
|
||||
|
||||
// Check if maintenance type has visits
|
||||
if (existingType.maintenanceVisits.length > 0) {
|
||||
// Soft delete - set as inactive instead of deleting
|
||||
await prisma.maintenanceType.update({
|
||||
where: { id },
|
||||
data: { isActive: false },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} else {
|
||||
// Hard delete if no visits are associated
|
||||
await prisma.maintenanceType.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting maintenance type:", error);
|
||||
return { success: false, error: "حدث خطأ أثناء حذف نوع الصيانة" };
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle maintenance type active status
|
||||
export async function toggleMaintenanceTypeStatus(
|
||||
id: number
|
||||
): Promise<{ success: boolean; maintenanceType?: MaintenanceType; error?: string }> {
|
||||
try {
|
||||
const existingType = await prisma.maintenanceType.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingType) {
|
||||
return { success: false, error: "نوع الصيانة غير موجود" };
|
||||
}
|
||||
|
||||
const maintenanceType = await prisma.maintenanceType.update({
|
||||
where: { id },
|
||||
data: { isActive: !existingType.isActive },
|
||||
});
|
||||
|
||||
return { success: true, maintenanceType };
|
||||
} catch (error) {
|
||||
console.error("Error toggling maintenance type status:", error);
|
||||
return { success: false, error: "حدث خطأ أثناء تغيير حالة نوع الصيانة" };
|
||||
}
|
||||
}
|
||||
|
||||
// Get maintenance type statistics
|
||||
export async function getMaintenanceTypeStats(typeId: number): Promise<{
|
||||
totalVisits: number;
|
||||
totalRevenue: number;
|
||||
averageCost: number;
|
||||
lastVisitDate?: Date;
|
||||
} | null> {
|
||||
const type = await prisma.maintenanceType.findUnique({
|
||||
where: { id: typeId },
|
||||
include: {
|
||||
maintenanceVisits: {
|
||||
select: {
|
||||
cost: true,
|
||||
visitDate: true,
|
||||
},
|
||||
orderBy: { visitDate: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!type) return null;
|
||||
|
||||
const totalRevenue = type.maintenanceVisits.reduce((sum, visit) => sum + visit.cost, 0);
|
||||
const averageCost = type.maintenanceVisits.length > 0
|
||||
? totalRevenue / type.maintenanceVisits.length
|
||||
: 0;
|
||||
const lastVisitDate = type.maintenanceVisits.length > 0
|
||||
? type.maintenanceVisits[0].visitDate
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
totalVisits: type.maintenanceVisits.length,
|
||||
totalRevenue,
|
||||
averageCost,
|
||||
lastVisitDate,
|
||||
};
|
||||
}
|
||||
365
app/lib/maintenance-visit-management.server.ts
Normal file
365
app/lib/maintenance-visit-management.server.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { prisma } from "./db.server";
|
||||
import type { MaintenanceVisit } from "@prisma/client";
|
||||
import type {
|
||||
CreateMaintenanceVisitData,
|
||||
UpdateMaintenanceVisitData,
|
||||
MaintenanceVisitWithRelations
|
||||
} from "~/types/database";
|
||||
|
||||
// Get all maintenance visits with search and pagination
|
||||
export async function getMaintenanceVisits(
|
||||
searchQuery?: string,
|
||||
page: number = 1,
|
||||
limit: number = 10,
|
||||
vehicleId?: number,
|
||||
customerId?: number
|
||||
): Promise<{
|
||||
visits: MaintenanceVisitWithRelations[];
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build where clause for search and filters
|
||||
const whereClause: any = {};
|
||||
|
||||
if (vehicleId) {
|
||||
whereClause.vehicleId = vehicleId;
|
||||
}
|
||||
|
||||
if (customerId) {
|
||||
whereClause.customerId = customerId;
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
whereClause.OR = [
|
||||
{ maintenanceJobs: { contains: searchLower } },
|
||||
{ description: { contains: searchLower } },
|
||||
{ paymentStatus: { contains: searchLower } },
|
||||
{ vehicle: { plateNumber: { contains: searchLower } } },
|
||||
{ customer: { name: { contains: searchLower } } },
|
||||
];
|
||||
}
|
||||
|
||||
const [visits, total] = await Promise.all([
|
||||
prisma.maintenanceVisit.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
vehicle: {
|
||||
select: {
|
||||
id: true,
|
||||
plateNumber: true,
|
||||
manufacturer: true,
|
||||
model: true,
|
||||
year: true,
|
||||
},
|
||||
},
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
income: {
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
incomeDate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { visitDate: 'desc' },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.maintenanceVisit.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return {
|
||||
visits,
|
||||
total,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
// Get a single maintenance visit by ID
|
||||
export async function getMaintenanceVisitById(id: number): Promise<MaintenanceVisitWithRelations | null> {
|
||||
return prisma.maintenanceVisit.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
vehicle: {
|
||||
select: {
|
||||
id: true,
|
||||
plateNumber: true,
|
||||
manufacturer: true,
|
||||
model: true,
|
||||
year: true,
|
||||
ownerId: true,
|
||||
},
|
||||
},
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
income: {
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
incomeDate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new maintenance visit
|
||||
export async function createMaintenanceVisit(data: CreateMaintenanceVisitData): Promise<MaintenanceVisit> {
|
||||
// Calculate next visit date based on delay
|
||||
const nextVisitDate = new Date();
|
||||
nextVisitDate.setMonth(nextVisitDate.getMonth() + data.nextVisitDelay);
|
||||
|
||||
// Start a transaction to create visit, update vehicle, and create income
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// Create the maintenance visit
|
||||
const visit = await tx.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: data.vehicleId,
|
||||
customerId: data.customerId,
|
||||
maintenanceJobs: JSON.stringify(data.maintenanceJobs),
|
||||
description: data.description,
|
||||
cost: data.cost,
|
||||
paymentStatus: data.paymentStatus || "pending",
|
||||
kilometers: data.kilometers,
|
||||
visitDate: data.visitDate || new Date(),
|
||||
nextVisitDelay: data.nextVisitDelay,
|
||||
},
|
||||
});
|
||||
|
||||
// Update vehicle's last visit date and suggested next visit date
|
||||
await tx.vehicle.update({
|
||||
where: { id: data.vehicleId },
|
||||
data: {
|
||||
lastVisitDate: visit.visitDate,
|
||||
suggestedNextVisitDate: nextVisitDate,
|
||||
},
|
||||
});
|
||||
|
||||
// Create income record
|
||||
await tx.income.create({
|
||||
data: {
|
||||
maintenanceVisitId: visit.id,
|
||||
amount: data.cost,
|
||||
incomeDate: visit.visitDate,
|
||||
},
|
||||
});
|
||||
|
||||
return visit;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Update an existing maintenance visit
|
||||
export async function updateMaintenanceVisit(
|
||||
id: number,
|
||||
data: UpdateMaintenanceVisitData
|
||||
): Promise<MaintenanceVisit> {
|
||||
// Calculate next visit date if delay is updated
|
||||
let nextVisitDate: Date | undefined;
|
||||
if (data.nextVisitDelay) {
|
||||
nextVisitDate = new Date(data.visitDate || new Date());
|
||||
nextVisitDate.setMonth(nextVisitDate.getMonth() + data.nextVisitDelay);
|
||||
}
|
||||
|
||||
// Start a transaction to update visit, vehicle, and income
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// Get the current visit to check for changes
|
||||
const currentVisit = await tx.maintenanceVisit.findUnique({
|
||||
where: { id },
|
||||
select: { vehicleId: true, cost: true, visitDate: true },
|
||||
});
|
||||
|
||||
if (!currentVisit) {
|
||||
throw new Error("Maintenance visit not found");
|
||||
}
|
||||
|
||||
// Update the maintenance visit
|
||||
const visit = await tx.maintenanceVisit.update({
|
||||
where: { id },
|
||||
data: {
|
||||
maintenanceJobs: data.maintenanceJobs ? JSON.stringify(data.maintenanceJobs) : undefined,
|
||||
description: data.description,
|
||||
cost: data.cost,
|
||||
paymentStatus: data.paymentStatus,
|
||||
kilometers: data.kilometers,
|
||||
visitDate: data.visitDate,
|
||||
nextVisitDelay: data.nextVisitDelay,
|
||||
},
|
||||
});
|
||||
|
||||
// Update vehicle if visit date or delay changed
|
||||
if (data.visitDate || data.nextVisitDelay) {
|
||||
const updateData: any = {};
|
||||
|
||||
if (data.visitDate) {
|
||||
updateData.lastVisitDate = data.visitDate;
|
||||
}
|
||||
|
||||
if (nextVisitDate) {
|
||||
updateData.suggestedNextVisitDate = nextVisitDate;
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await tx.vehicle.update({
|
||||
where: { id: currentVisit.vehicleId },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update income record if cost or date changed
|
||||
if (data.cost !== undefined || data.visitDate) {
|
||||
await tx.income.updateMany({
|
||||
where: { maintenanceVisitId: id },
|
||||
data: {
|
||||
amount: data.cost || currentVisit.cost,
|
||||
incomeDate: data.visitDate || currentVisit.visitDate,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return visit;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Delete a maintenance visit
|
||||
export async function deleteMaintenanceVisit(id: number): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Get visit details before deletion
|
||||
const visit = await tx.maintenanceVisit.findUnique({
|
||||
where: { id },
|
||||
select: { vehicleId: true },
|
||||
});
|
||||
|
||||
if (!visit) {
|
||||
throw new Error("Maintenance visit not found");
|
||||
}
|
||||
|
||||
// Delete the maintenance visit (income will be deleted by cascade)
|
||||
await tx.maintenanceVisit.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
// Update vehicle's last visit date to the most recent remaining visit
|
||||
const lastVisit = await tx.maintenanceVisit.findFirst({
|
||||
where: { vehicleId: visit.vehicleId },
|
||||
orderBy: { visitDate: 'desc' },
|
||||
select: { visitDate: true, nextVisitDelay: true },
|
||||
});
|
||||
|
||||
let updateData: any = {};
|
||||
|
||||
if (lastVisit) {
|
||||
updateData.lastVisitDate = lastVisit.visitDate;
|
||||
// Recalculate next visit date
|
||||
const nextDate = new Date(lastVisit.visitDate);
|
||||
nextDate.setMonth(nextDate.getMonth() + lastVisit.nextVisitDelay);
|
||||
updateData.suggestedNextVisitDate = nextDate;
|
||||
} else {
|
||||
// No visits left, clear the dates
|
||||
updateData.lastVisitDate = null;
|
||||
updateData.suggestedNextVisitDate = null;
|
||||
}
|
||||
|
||||
await tx.vehicle.update({
|
||||
where: { id: visit.vehicleId },
|
||||
data: updateData,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Get maintenance visits for a specific vehicle
|
||||
export async function getVehicleMaintenanceHistory(vehicleId: number): Promise<MaintenanceVisitWithRelations[]> {
|
||||
return prisma.maintenanceVisit.findMany({
|
||||
where: { vehicleId },
|
||||
include: {
|
||||
vehicle: {
|
||||
select: {
|
||||
id: true,
|
||||
plateNumber: true,
|
||||
manufacturer: true,
|
||||
model: true,
|
||||
year: true,
|
||||
},
|
||||
},
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
income: {
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
incomeDate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { visitDate: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
// Get maintenance visits for a specific customer
|
||||
export async function getCustomerMaintenanceHistory(customerId: number): Promise<MaintenanceVisitWithRelations[]> {
|
||||
return prisma.maintenanceVisit.findMany({
|
||||
where: { customerId },
|
||||
include: {
|
||||
vehicle: {
|
||||
select: {
|
||||
id: true,
|
||||
plateNumber: true,
|
||||
manufacturer: true,
|
||||
model: true,
|
||||
year: true,
|
||||
},
|
||||
},
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
maintenanceType: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
income: {
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
incomeDate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { visitDate: 'desc' },
|
||||
});
|
||||
}
|
||||
209
app/lib/settings-management.server.ts
Normal file
209
app/lib/settings-management.server.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { prisma as db } from './db.server';
|
||||
|
||||
export interface AppSettings {
|
||||
dateFormat: 'ar-SA' | 'en-US';
|
||||
currency: string;
|
||||
numberFormat: 'ar-SA' | 'en-US';
|
||||
currencySymbol: string;
|
||||
dateDisplayFormat: string;
|
||||
}
|
||||
|
||||
export interface SettingItem {
|
||||
id: number;
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
createdDate: Date;
|
||||
updateDate: Date;
|
||||
}
|
||||
|
||||
// Default settings
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
dateFormat: 'ar-SA',
|
||||
currency: 'JOD',
|
||||
numberFormat: 'ar-SA',
|
||||
currencySymbol: 'د.أ',
|
||||
dateDisplayFormat: 'dd/MM/yyyy'
|
||||
};
|
||||
|
||||
// Get all settings as a typed object
|
||||
export async function getAppSettings(): Promise<AppSettings> {
|
||||
try {
|
||||
const settings = await db.settings.findMany();
|
||||
|
||||
const settingsMap = settings.reduce((acc, setting) => {
|
||||
acc[setting.key] = setting.value;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
return {
|
||||
dateFormat: (settingsMap.dateFormat as 'ar-SA' | 'en-US') || DEFAULT_SETTINGS.dateFormat,
|
||||
currency: settingsMap.currency || DEFAULT_SETTINGS.currency,
|
||||
numberFormat: (settingsMap.numberFormat as 'ar-SA' | 'en-US') || DEFAULT_SETTINGS.numberFormat,
|
||||
currencySymbol: settingsMap.currencySymbol || DEFAULT_SETTINGS.currencySymbol,
|
||||
dateDisplayFormat: settingsMap.dateDisplayFormat || DEFAULT_SETTINGS.dateDisplayFormat
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching settings:', error);
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
// Get a specific setting by key
|
||||
export async function getSetting(key: string): Promise<string | null> {
|
||||
try {
|
||||
const setting = await db.settings.findUnique({
|
||||
where: { key }
|
||||
});
|
||||
return setting?.value || null;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching setting ${key}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Update or create a setting
|
||||
export async function updateSetting(key: string, value: string, description?: string): Promise<SettingItem> {
|
||||
try {
|
||||
return await db.settings.upsert({
|
||||
where: { key },
|
||||
update: {
|
||||
value,
|
||||
description: description || undefined,
|
||||
updateDate: new Date()
|
||||
},
|
||||
create: {
|
||||
key,
|
||||
value,
|
||||
description: description || undefined
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error updating setting ${key}:`, error);
|
||||
throw new Error(`Failed to update setting: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Update multiple settings at once
|
||||
export async function updateSettings(settings: Partial<AppSettings>): Promise<void> {
|
||||
try {
|
||||
const updates = Object.entries(settings).map(([key, value]) =>
|
||||
updateSetting(key, value as string)
|
||||
);
|
||||
|
||||
await Promise.all(updates);
|
||||
} catch (error) {
|
||||
console.error('Error updating settings:', error);
|
||||
throw new Error('Failed to update settings');
|
||||
}
|
||||
}
|
||||
|
||||
// Get all settings for admin management
|
||||
export async function getAllSettings(): Promise<SettingItem[]> {
|
||||
try {
|
||||
return await db.settings.findMany({
|
||||
orderBy: { key: 'asc' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching all settings:', error);
|
||||
throw new Error('Failed to fetch settings');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize default settings if they don't exist
|
||||
export async function initializeDefaultSettings(): Promise<void> {
|
||||
try {
|
||||
const existingSettings = await db.settings.findMany();
|
||||
const existingKeys = new Set(existingSettings.map(s => s.key));
|
||||
|
||||
const defaultEntries = [
|
||||
{ key: 'dateFormat', value: 'ar-SA', description: 'Date format locale (ar-SA or en-US)' },
|
||||
{ key: 'currency', value: 'JOD', description: 'Currency code (JOD, USD, EUR, etc.)' },
|
||||
{ key: 'numberFormat', value: 'ar-SA', description: 'Number format locale (ar-SA or en-US)' },
|
||||
{ key: 'currencySymbol', value: 'د.أ', description: 'Currency symbol display' },
|
||||
{ key: 'dateDisplayFormat', value: 'dd/MM/yyyy', description: 'Date display format pattern' }
|
||||
];
|
||||
|
||||
const newSettings = defaultEntries.filter(entry => !existingKeys.has(entry.key));
|
||||
|
||||
if (newSettings.length > 0) {
|
||||
await db.settings.createMany({
|
||||
data: newSettings
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing default settings:', error);
|
||||
// Don't throw error, just log it and continue with defaults
|
||||
}
|
||||
}
|
||||
|
||||
// Formatting utilities using settings
|
||||
export class SettingsFormatter {
|
||||
constructor(private settings: AppSettings) {}
|
||||
|
||||
// Format number according to settings
|
||||
formatNumber(value: number): string {
|
||||
return value.toLocaleString(this.settings.numberFormat);
|
||||
}
|
||||
|
||||
// Format currency according to settings
|
||||
formatCurrency(value: number): string {
|
||||
const formatted = value.toLocaleString(this.settings.numberFormat, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
return `${formatted} ${this.settings.currencySymbol}`;
|
||||
}
|
||||
|
||||
// Format date according to settings
|
||||
formatDate(date: Date | string): string {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
return this.formatDateWithPattern(dateObj, this.settings.dateDisplayFormat);
|
||||
}
|
||||
|
||||
// Format datetime according to settings
|
||||
formatDateTime(date: Date | string): string {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
const datePart = this.formatDateWithPattern(dateObj, this.settings.dateDisplayFormat);
|
||||
const timePart = dateObj.toLocaleTimeString(this.settings.dateFormat, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
return `${datePart} ${timePart}`;
|
||||
}
|
||||
|
||||
// Helper method to format date with custom pattern
|
||||
private formatDateWithPattern(date: Date, pattern: string): string {
|
||||
const day = date.getDate();
|
||||
const month = date.getMonth() + 1;
|
||||
const year = date.getFullYear();
|
||||
|
||||
// Format numbers according to locale
|
||||
const formatNumber = (num: number, padLength: number = 2): string => {
|
||||
const padded = num.toString().padStart(padLength, '0');
|
||||
return this.settings.numberFormat === 'ar-SA'
|
||||
? this.convertToArabicNumerals(padded)
|
||||
: padded;
|
||||
};
|
||||
|
||||
return pattern
|
||||
.replace(/yyyy/g, formatNumber(year, 4))
|
||||
.replace(/yy/g, formatNumber(year % 100, 2))
|
||||
.replace(/MM/g, formatNumber(month, 2))
|
||||
.replace(/M/g, formatNumber(month, 1))
|
||||
.replace(/dd/g, formatNumber(day, 2))
|
||||
.replace(/d/g, formatNumber(day, 1));
|
||||
}
|
||||
|
||||
// Helper method to convert Western numerals to Arabic numerals
|
||||
private convertToArabicNumerals(str: string): string {
|
||||
const arabicNumerals = ['٠', '١', '٢', '٣', '٤', '٥', '٦', '٧', '٨', '٩'];
|
||||
return str.replace(/[0-9]/g, (digit) => arabicNumerals[parseInt(digit)]);
|
||||
}
|
||||
}
|
||||
|
||||
// Create formatter instance with current settings
|
||||
export async function createFormatter(): Promise<SettingsFormatter> {
|
||||
const settings = await getAppSettings();
|
||||
return new SettingsFormatter(settings);
|
||||
}
|
||||
309
app/lib/table-utils.ts
Normal file
309
app/lib/table-utils.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
// Table utilities for searching, filtering, sorting, and pagination
|
||||
|
||||
export interface SortConfig {
|
||||
key: string;
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface FilterConfig {
|
||||
[key: string]: string | string[] | number | boolean;
|
||||
}
|
||||
|
||||
export interface PaginationConfig {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface TableState {
|
||||
search: string;
|
||||
filters: FilterConfig;
|
||||
sort: SortConfig | null;
|
||||
pagination: PaginationConfig;
|
||||
}
|
||||
|
||||
// Search function with Arabic text support
|
||||
export function searchData<T extends Record<string, any>>(
|
||||
data: T[],
|
||||
searchTerm: string,
|
||||
searchableFields: (keyof T)[]
|
||||
): T[] {
|
||||
if (!searchTerm.trim()) return data;
|
||||
|
||||
const normalizedSearch = normalizeArabicText(searchTerm.toLowerCase());
|
||||
|
||||
return data.filter(item => {
|
||||
return searchableFields.some(field => {
|
||||
const value = item[field];
|
||||
if (value == null) return false;
|
||||
|
||||
const normalizedValue = normalizeArabicText(String(value).toLowerCase());
|
||||
return normalizedValue.includes(normalizedSearch);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Filter data based on multiple criteria
|
||||
export function filterData<T extends Record<string, any>>(
|
||||
data: T[],
|
||||
filters: FilterConfig
|
||||
): T[] {
|
||||
return data.filter(item => {
|
||||
return Object.entries(filters).every(([key, filterValue]) => {
|
||||
if (!filterValue || filterValue === '' || (Array.isArray(filterValue) && filterValue.length === 0)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const itemValue = item[key];
|
||||
|
||||
if (Array.isArray(filterValue)) {
|
||||
// Multi-select filter
|
||||
return filterValue.includes(String(itemValue));
|
||||
}
|
||||
|
||||
if (typeof filterValue === 'string') {
|
||||
// Text filter
|
||||
if (itemValue == null) return false;
|
||||
const normalizedItemValue = normalizeArabicText(String(itemValue).toLowerCase());
|
||||
const normalizedFilterValue = normalizeArabicText(filterValue.toLowerCase());
|
||||
return normalizedItemValue.includes(normalizedFilterValue);
|
||||
}
|
||||
|
||||
if (typeof filterValue === 'number') {
|
||||
// Numeric filter
|
||||
return Number(itemValue) === filterValue;
|
||||
}
|
||||
|
||||
if (typeof filterValue === 'boolean') {
|
||||
// Boolean filter
|
||||
return Boolean(itemValue) === filterValue;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Sort data
|
||||
export function sortData<T extends Record<string, any>>(
|
||||
data: T[],
|
||||
sortConfig: SortConfig | null
|
||||
): T[] {
|
||||
if (!sortConfig) return data;
|
||||
|
||||
return [...data].sort((a, b) => {
|
||||
const aValue = a[sortConfig.key];
|
||||
const bValue = b[sortConfig.key];
|
||||
|
||||
// Handle null/undefined values
|
||||
if (aValue == null && bValue == null) return 0;
|
||||
if (aValue == null) return sortConfig.direction === 'asc' ? 1 : -1;
|
||||
if (bValue == null) return sortConfig.direction === 'asc' ? -1 : 1;
|
||||
|
||||
// Handle different data types
|
||||
let comparison = 0;
|
||||
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
// String comparison with Arabic support
|
||||
comparison = normalizeArabicText(aValue).localeCompare(normalizeArabicText(bValue), 'ar');
|
||||
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
// Numeric comparison
|
||||
comparison = aValue - bValue;
|
||||
} else if (aValue instanceof Date && bValue instanceof Date) {
|
||||
// Date comparison
|
||||
comparison = aValue.getTime() - bValue.getTime();
|
||||
} else {
|
||||
// Fallback to string comparison
|
||||
comparison = String(aValue).localeCompare(String(bValue), 'ar');
|
||||
}
|
||||
|
||||
return sortConfig.direction === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
// Paginate data
|
||||
export function paginateData<T>(
|
||||
data: T[],
|
||||
pagination: PaginationConfig
|
||||
): {
|
||||
data: T[];
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
hasNextPage: boolean;
|
||||
hasPreviousPage: boolean;
|
||||
} {
|
||||
const { page, pageSize } = pagination;
|
||||
const totalItems = data.length;
|
||||
const totalPages = Math.ceil(totalItems / pageSize);
|
||||
const startIndex = (page - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedData = data.slice(startIndex, endIndex);
|
||||
|
||||
return {
|
||||
data: paginatedData,
|
||||
totalItems,
|
||||
totalPages,
|
||||
currentPage: page,
|
||||
hasNextPage: page < totalPages,
|
||||
hasPreviousPage: page > 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Process table data with all operations
|
||||
export function processTableData<T extends Record<string, any>>(
|
||||
data: T[],
|
||||
state: TableState,
|
||||
searchableFields: (keyof T)[]
|
||||
) {
|
||||
let processedData = [...data];
|
||||
|
||||
// Apply search
|
||||
if (state.search) {
|
||||
processedData = searchData(processedData, state.search, searchableFields);
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if (Object.keys(state.filters).length > 0) {
|
||||
processedData = filterData(processedData, state.filters);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
if (state.sort) {
|
||||
processedData = sortData(processedData, state.sort);
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
const paginationResult = paginateData(processedData, state.pagination);
|
||||
|
||||
return {
|
||||
...paginationResult,
|
||||
filteredCount: processedData.length,
|
||||
originalCount: data.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize Arabic text for better searching
|
||||
function normalizeArabicText(text: string): string {
|
||||
return text
|
||||
// Normalize Arabic characters
|
||||
.replace(/[أإآ]/g, 'ا')
|
||||
.replace(/[ة]/g, 'ه')
|
||||
.replace(/[ي]/g, 'ى')
|
||||
// Remove diacritics
|
||||
.replace(/[\u064B-\u065F\u0670\u06D6-\u06ED]/g, '')
|
||||
// Normalize whitespace
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Generate filter options from data
|
||||
export function generateFilterOptions<T extends Record<string, any>>(
|
||||
data: T[],
|
||||
field: keyof T,
|
||||
labelFormatter?: (value: any) => string
|
||||
): { value: string; label: string }[] {
|
||||
const uniqueValues = Array.from(new Set(
|
||||
data
|
||||
.map(item => item[field])
|
||||
.filter(value => value != null && value !== '')
|
||||
));
|
||||
|
||||
return uniqueValues
|
||||
.sort((a, b) => {
|
||||
if (typeof a === 'string' && typeof b === 'string') {
|
||||
return normalizeArabicText(a).localeCompare(normalizeArabicText(b), 'ar');
|
||||
}
|
||||
return String(a).localeCompare(String(b));
|
||||
})
|
||||
.map(value => ({
|
||||
value: String(value),
|
||||
label: labelFormatter ? labelFormatter(value) : String(value),
|
||||
}));
|
||||
}
|
||||
|
||||
// Create table state from URL search params
|
||||
export function createTableStateFromParams(
|
||||
searchParams: URLSearchParams,
|
||||
defaultPageSize = 10
|
||||
): TableState {
|
||||
return {
|
||||
search: searchParams.get('search') || '',
|
||||
filters: Object.fromEntries(
|
||||
Array.from(searchParams.entries())
|
||||
.filter(([key]) => key.startsWith('filter_'))
|
||||
.map(([key, value]) => [key.replace('filter_', ''), value])
|
||||
),
|
||||
sort: searchParams.get('sort') && searchParams.get('sortDir') ? {
|
||||
key: searchParams.get('sort')!,
|
||||
direction: searchParams.get('sortDir') as 'asc' | 'desc',
|
||||
} : null,
|
||||
pagination: {
|
||||
page: parseInt(searchParams.get('page') || '1'),
|
||||
pageSize: parseInt(searchParams.get('pageSize') || String(defaultPageSize)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Convert table state to URL search params
|
||||
export function tableStateToParams(state: TableState): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (state.search) {
|
||||
params.set('search', state.search);
|
||||
}
|
||||
|
||||
Object.entries(state.filters).forEach(([key, value]) => {
|
||||
if (value && value !== '') {
|
||||
params.set(`filter_${key}`, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
if (state.sort) {
|
||||
params.set('sort', state.sort.key);
|
||||
params.set('sortDir', state.sort.direction);
|
||||
}
|
||||
|
||||
if (state.pagination.page > 1) {
|
||||
params.set('page', String(state.pagination.page));
|
||||
}
|
||||
|
||||
if (state.pagination.pageSize !== 10) {
|
||||
params.set('pageSize', String(state.pagination.pageSize));
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
// Debounce function for search input
|
||||
export function debounce<T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
// Export utility types
|
||||
export type TableColumn<T> = {
|
||||
key: keyof T;
|
||||
header: string;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
render?: (value: any, item: T) => React.ReactNode;
|
||||
width?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
};
|
||||
|
||||
export type TableAction<T> = {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: (item: T) => void;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
disabled?: (item: T) => boolean;
|
||||
hidden?: (item: T) => boolean;
|
||||
};
|
||||
341
app/lib/user-management.server.ts
Normal file
341
app/lib/user-management.server.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import { prisma } from "./db.server";
|
||||
import { hashPassword } from "./auth.server";
|
||||
import type { User } from "@prisma/client";
|
||||
import type { CreateUserData, UpdateUserData, UserWithoutPassword } from "~/types/database";
|
||||
import { AUTH_LEVELS, USER_STATUS } from "~/types/auth";
|
||||
|
||||
// Get all users with role-based filtering
|
||||
export async function getUsers(
|
||||
currentUserAuthLevel: number,
|
||||
searchQuery?: string,
|
||||
page: number = 1,
|
||||
limit: number = 10
|
||||
): Promise<{
|
||||
users: UserWithoutPassword[];
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build where clause based on current user's auth level
|
||||
const whereClause: any = {};
|
||||
|
||||
// Admins cannot see superadmin accounts
|
||||
if (currentUserAuthLevel === AUTH_LEVELS.ADMIN) {
|
||||
whereClause.authLevel = {
|
||||
gt: AUTH_LEVELS.SUPERADMIN,
|
||||
};
|
||||
}
|
||||
|
||||
// Add search functionality
|
||||
if (searchQuery) {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
whereClause.OR = [
|
||||
{ name: { contains: searchLower } },
|
||||
{ username: { contains: searchLower } },
|
||||
{ email: { contains: searchLower } },
|
||||
];
|
||||
}
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
status: true,
|
||||
authLevel: true,
|
||||
createdDate: true,
|
||||
editDate: true,
|
||||
},
|
||||
orderBy: { createdDate: 'desc' },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.user.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
return {
|
||||
users,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
// Get user by ID with role-based access control
|
||||
export async function getUserById(
|
||||
id: number,
|
||||
currentUserAuthLevel: number
|
||||
): Promise<UserWithoutPassword | null> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
status: true,
|
||||
authLevel: true,
|
||||
createdDate: true,
|
||||
editDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
// Admins cannot access superadmin accounts
|
||||
if (currentUserAuthLevel === AUTH_LEVELS.ADMIN && user.authLevel === AUTH_LEVELS.SUPERADMIN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
// Create new user
|
||||
export async function createUser(
|
||||
userData: CreateUserData,
|
||||
currentUserAuthLevel: number
|
||||
): Promise<{ success: boolean; user?: UserWithoutPassword; error?: string }> {
|
||||
try {
|
||||
// Validate that current user can create users with the specified auth level
|
||||
if (currentUserAuthLevel === AUTH_LEVELS.ADMIN && userData.authLevel === AUTH_LEVELS.SUPERADMIN) {
|
||||
return { success: false, error: "لا يمكن للمدير إنشاء حساب مدير عام" };
|
||||
}
|
||||
|
||||
// Check if username or email already exists
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: userData.username },
|
||||
{ email: userData.email },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
if (existingUser.username === userData.username) {
|
||||
return { success: false, error: "اسم المستخدم موجود بالفعل" };
|
||||
}
|
||||
if (existingUser.email === userData.email) {
|
||||
return { success: false, error: "البريد الإلكتروني موجود بالفعل" };
|
||||
}
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const hashedPassword = await hashPassword(userData.password);
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: userData.name,
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
password: hashedPassword,
|
||||
authLevel: userData.authLevel,
|
||||
status: userData.status || USER_STATUS.ACTIVE,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
status: true,
|
||||
authLevel: true,
|
||||
createdDate: true,
|
||||
editDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, user };
|
||||
} catch (error) {
|
||||
console.error("Error creating user:", error);
|
||||
return { success: false, error: "حدث خطأ أثناء إنشاء المستخدم" };
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
export async function updateUser(
|
||||
id: number,
|
||||
userData: UpdateUserData,
|
||||
currentUserAuthLevel: number
|
||||
): Promise<{ success: boolean; user?: UserWithoutPassword; error?: string }> {
|
||||
try {
|
||||
// Get existing user to check permissions
|
||||
const existingUser = await getUserById(id, currentUserAuthLevel);
|
||||
if (!existingUser) {
|
||||
return { success: false, error: "المستخدم غير موجود أو لا يمكن الوصول إليه" };
|
||||
}
|
||||
|
||||
// Validate auth level changes
|
||||
if (userData.authLevel !== undefined) {
|
||||
if (currentUserAuthLevel === AUTH_LEVELS.ADMIN && userData.authLevel === AUTH_LEVELS.SUPERADMIN) {
|
||||
return { success: false, error: "لا يمكن للمدير تعيين مستوى مدير عام" };
|
||||
}
|
||||
}
|
||||
|
||||
// Check for username/email conflicts
|
||||
if (userData.username || userData.email) {
|
||||
const conflictUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{ id: { not: id } },
|
||||
{
|
||||
OR: [
|
||||
userData.username ? { username: userData.username } : {},
|
||||
userData.email ? { email: userData.email } : {},
|
||||
].filter(condition => Object.keys(condition).length > 0),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (conflictUser) {
|
||||
if (conflictUser.username === userData.username) {
|
||||
return { success: false, error: "اسم المستخدم موجود بالفعل" };
|
||||
}
|
||||
if (conflictUser.email === userData.email) {
|
||||
return { success: false, error: "البريد الإلكتروني موجود بالفعل" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: any = {};
|
||||
if (userData.name !== undefined) updateData.name = userData.name;
|
||||
if (userData.username !== undefined) updateData.username = userData.username;
|
||||
if (userData.email !== undefined) updateData.email = userData.email;
|
||||
if (userData.authLevel !== undefined) updateData.authLevel = userData.authLevel;
|
||||
if (userData.status !== undefined) updateData.status = userData.status;
|
||||
|
||||
// Hash new password if provided
|
||||
if (userData.password) {
|
||||
updateData.password = await hashPassword(userData.password);
|
||||
}
|
||||
|
||||
// Update user
|
||||
const user = await prisma.user.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
status: true,
|
||||
authLevel: true,
|
||||
createdDate: true,
|
||||
editDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, user };
|
||||
} catch (error) {
|
||||
console.error("Error updating user:", error);
|
||||
return { success: false, error: "حدث خطأ أثناء تحديث المستخدم" };
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user
|
||||
export async function deleteUser(
|
||||
id: number,
|
||||
currentUserAuthLevel: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Get existing user to check permissions
|
||||
const existingUser = await getUserById(id, currentUserAuthLevel);
|
||||
if (!existingUser) {
|
||||
return { success: false, error: "المستخدم غير موجود أو لا يمكن الوصول إليه" };
|
||||
}
|
||||
|
||||
// Prevent deletion of superadmin by admin
|
||||
if (currentUserAuthLevel === AUTH_LEVELS.ADMIN && existingUser.authLevel === AUTH_LEVELS.SUPERADMIN) {
|
||||
return { success: false, error: "لا يمكن للمدير حذف حساب مدير عام" };
|
||||
}
|
||||
|
||||
// Check if this is the last superadmin
|
||||
if (existingUser.authLevel === AUTH_LEVELS.SUPERADMIN) {
|
||||
const superadminCount = await prisma.user.count({
|
||||
where: { authLevel: AUTH_LEVELS.SUPERADMIN },
|
||||
});
|
||||
|
||||
if (superadminCount <= 1) {
|
||||
return { success: false, error: "لا يمكن حذف آخر مدير عام في النظام" };
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user
|
||||
await prisma.user.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error deleting user:", error);
|
||||
return { success: false, error: "حدث خطأ أثناء حذف المستخدم" };
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle user status (active/inactive)
|
||||
export async function toggleUserStatus(
|
||||
id: number,
|
||||
currentUserAuthLevel: number
|
||||
): Promise<{ success: boolean; user?: UserWithoutPassword; error?: string }> {
|
||||
try {
|
||||
const existingUser = await getUserById(id, currentUserAuthLevel);
|
||||
if (!existingUser) {
|
||||
return { success: false, error: "المستخدم غير موجود أو لا يمكن الوصول إليه" };
|
||||
}
|
||||
|
||||
const newStatus = existingUser.status === USER_STATUS.ACTIVE
|
||||
? USER_STATUS.INACTIVE
|
||||
: USER_STATUS.ACTIVE;
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id },
|
||||
data: { status: newStatus },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
status: true,
|
||||
authLevel: true,
|
||||
createdDate: true,
|
||||
editDate: true,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, user };
|
||||
} catch (error) {
|
||||
console.error("Error toggling user status:", error);
|
||||
return { success: false, error: "حدث خطأ أثناء تغيير حالة المستخدم" };
|
||||
}
|
||||
}
|
||||
|
||||
// Get auth level display name
|
||||
export function getAuthLevelName(authLevel: number): string {
|
||||
switch (authLevel) {
|
||||
case AUTH_LEVELS.SUPERADMIN:
|
||||
return "مدير عام";
|
||||
case AUTH_LEVELS.ADMIN:
|
||||
return "مدير";
|
||||
case AUTH_LEVELS.USER:
|
||||
return "مستخدم";
|
||||
default:
|
||||
return "غير محدد";
|
||||
}
|
||||
}
|
||||
|
||||
// Get status display name
|
||||
export function getStatusName(status: string): string {
|
||||
switch (status) {
|
||||
case USER_STATUS.ACTIVE:
|
||||
return "نشط";
|
||||
case USER_STATUS.INACTIVE:
|
||||
return "غير نشط";
|
||||
default:
|
||||
return "غير محدد";
|
||||
}
|
||||
}
|
||||
27
app/lib/user-utils.ts
Normal file
27
app/lib/user-utils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { AUTH_LEVELS, USER_STATUS } from '~/types/auth';
|
||||
|
||||
// Get auth level display name (client-side version)
|
||||
export function getAuthLevelName(authLevel: number): string {
|
||||
switch (authLevel) {
|
||||
case AUTH_LEVELS.SUPERADMIN:
|
||||
return "مدير عام";
|
||||
case AUTH_LEVELS.ADMIN:
|
||||
return "مدير";
|
||||
case AUTH_LEVELS.USER:
|
||||
return "مستخدم";
|
||||
default:
|
||||
return "غير محدد";
|
||||
}
|
||||
}
|
||||
|
||||
// Get status display name (client-side version)
|
||||
export function getStatusName(status: string): string {
|
||||
switch (status) {
|
||||
case USER_STATUS.ACTIVE:
|
||||
return "نشط";
|
||||
case USER_STATUS.INACTIVE:
|
||||
return "غير نشط";
|
||||
default:
|
||||
return "غير محدد";
|
||||
}
|
||||
}
|
||||
291
app/lib/validation-utils.ts
Normal file
291
app/lib/validation-utils.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
// Validation utility functions with Arabic error messages
|
||||
|
||||
export interface ValidationRule {
|
||||
required?: boolean;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
pattern?: RegExp;
|
||||
email?: boolean;
|
||||
phone?: boolean;
|
||||
url?: boolean;
|
||||
custom?: (value: any) => string | null;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Arabic error messages
|
||||
export const ERROR_MESSAGES = {
|
||||
required: 'هذا الحقل مطلوب',
|
||||
email: 'البريد الإلكتروني غير صحيح',
|
||||
phone: 'رقم الهاتف غير صحيح',
|
||||
url: 'الرابط غير صحيح',
|
||||
minLength: (min: number) => `يجب أن يكون على الأقل ${min} أحرف`,
|
||||
maxLength: (max: number) => `يجب أن يكون أقل من ${max} حرف`,
|
||||
min: (min: number) => `يجب أن يكون على الأقل ${min}`,
|
||||
max: (max: number) => `يجب أن يكون أقل من ${max}`,
|
||||
pattern: 'التنسيق غير صحيح',
|
||||
number: 'يجب أن يكون رقم صحيح',
|
||||
integer: 'يجب أن يكون رقم صحيح',
|
||||
positive: 'يجب أن يكون رقم موجب',
|
||||
negative: 'يجب أن يكون رقم سالب',
|
||||
date: 'التاريخ غير صحيح',
|
||||
time: 'الوقت غير صحيح',
|
||||
datetime: 'التاريخ والوقت غير صحيح',
|
||||
};
|
||||
|
||||
// Regular expressions for validation
|
||||
export const PATTERNS = {
|
||||
email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
phone: /^[\+]?[0-9\s\-\(\)]+$/,
|
||||
url: /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/,
|
||||
arabicText: /^[\u0600-\u06FF\s\d\p{P}]+$/u,
|
||||
englishText: /^[a-zA-Z\s\d\p{P}]+$/u,
|
||||
alphanumeric: /^[a-zA-Z0-9]+$/,
|
||||
numeric: /^\d+$/,
|
||||
decimal: /^\d+(\.\d+)?$/,
|
||||
plateNumber: /^[A-Z0-9\u0600-\u06FF\s\-]+$/u,
|
||||
username: /^[a-zA-Z0-9_]+$/,
|
||||
};
|
||||
|
||||
// Validate a single field
|
||||
export function validateField(value: any, rules: ValidationRule): ValidationResult {
|
||||
// Convert value to string for most validations
|
||||
const stringValue = value != null ? String(value).trim() : '';
|
||||
|
||||
// Required validation
|
||||
if (rules.required && (!value || stringValue === '')) {
|
||||
return { isValid: false, error: ERROR_MESSAGES.required };
|
||||
}
|
||||
|
||||
// Skip other validations if value is empty and not required
|
||||
if (!rules.required && (!value || stringValue === '')) {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (rules.email && !PATTERNS.email.test(stringValue)) {
|
||||
return { isValid: false, error: ERROR_MESSAGES.email };
|
||||
}
|
||||
|
||||
// Phone validation
|
||||
if (rules.phone && !PATTERNS.phone.test(stringValue)) {
|
||||
return { isValid: false, error: ERROR_MESSAGES.phone };
|
||||
}
|
||||
|
||||
// URL validation
|
||||
if (rules.url && !PATTERNS.url.test(stringValue)) {
|
||||
return { isValid: false, error: ERROR_MESSAGES.url };
|
||||
}
|
||||
|
||||
// Pattern validation
|
||||
if (rules.pattern && !rules.pattern.test(stringValue)) {
|
||||
return { isValid: false, error: ERROR_MESSAGES.pattern };
|
||||
}
|
||||
|
||||
// Length validations
|
||||
if (rules.minLength && stringValue.length < rules.minLength) {
|
||||
return { isValid: false, error: ERROR_MESSAGES.minLength(rules.minLength) };
|
||||
}
|
||||
|
||||
if (rules.maxLength && stringValue.length > rules.maxLength) {
|
||||
return { isValid: false, error: ERROR_MESSAGES.maxLength(rules.maxLength) };
|
||||
}
|
||||
|
||||
// Numeric validations
|
||||
if (rules.min !== undefined || rules.max !== undefined) {
|
||||
const numValue = Number(value);
|
||||
if (isNaN(numValue)) {
|
||||
return { isValid: false, error: ERROR_MESSAGES.number };
|
||||
}
|
||||
|
||||
if (rules.min !== undefined && numValue < rules.min) {
|
||||
return { isValid: false, error: ERROR_MESSAGES.min(rules.min) };
|
||||
}
|
||||
|
||||
if (rules.max !== undefined && numValue > rules.max) {
|
||||
return { isValid: false, error: ERROR_MESSAGES.max(rules.max) };
|
||||
}
|
||||
}
|
||||
|
||||
// Custom validation
|
||||
if (rules.custom) {
|
||||
const customError = rules.custom(value);
|
||||
if (customError) {
|
||||
return { isValid: false, error: customError };
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
// Validate multiple fields
|
||||
export function validateFields(data: Record<string, any>, rules: Record<string, ValidationRule>): {
|
||||
isValid: boolean;
|
||||
errors: Record<string, string>;
|
||||
} {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
Object.entries(rules).forEach(([fieldName, fieldRules]) => {
|
||||
const result = validateField(data[fieldName], fieldRules);
|
||||
if (!result.isValid && result.error) {
|
||||
errors[fieldName] = result.error;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// Specific validation functions
|
||||
export function validateEmail(email: string): ValidationResult {
|
||||
return validateField(email, { required: true, email: true });
|
||||
}
|
||||
|
||||
export function validatePhone(phone: string): ValidationResult {
|
||||
return validateField(phone, { phone: true });
|
||||
}
|
||||
|
||||
export function validatePassword(password: string, minLength = 8): ValidationResult {
|
||||
return validateField(password, { required: true, minLength });
|
||||
}
|
||||
|
||||
export function validateUsername(username: string): ValidationResult {
|
||||
return validateField(username, {
|
||||
required: true,
|
||||
minLength: 3,
|
||||
pattern: PATTERNS.username
|
||||
});
|
||||
}
|
||||
|
||||
export function validatePlateNumber(plateNumber: string): ValidationResult {
|
||||
return validateField(plateNumber, {
|
||||
required: true,
|
||||
pattern: PATTERNS.plateNumber
|
||||
});
|
||||
}
|
||||
|
||||
export function validateYear(year: number, minYear = 1900, maxYear = new Date().getFullYear() + 1): ValidationResult {
|
||||
return validateField(year, {
|
||||
required: true,
|
||||
min: minYear,
|
||||
max: maxYear
|
||||
});
|
||||
}
|
||||
|
||||
export function validateCurrency(amount: number, min = 0, max = 999999999): ValidationResult {
|
||||
return validateField(amount, {
|
||||
required: true,
|
||||
min,
|
||||
max
|
||||
});
|
||||
}
|
||||
|
||||
// Form data sanitization
|
||||
export function sanitizeString(value: string): string {
|
||||
return value.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function sanitizeNumber(value: string | number): number | null {
|
||||
const num = Number(value);
|
||||
return isNaN(num) ? null : num;
|
||||
}
|
||||
|
||||
export function sanitizeInteger(value: string | number): number | null {
|
||||
const num = parseInt(String(value));
|
||||
return isNaN(num) ? null : num;
|
||||
}
|
||||
|
||||
export function sanitizeFormData(data: Record<string, any>): Record<string, any> {
|
||||
const sanitized: Record<string, any> = {};
|
||||
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
sanitized[key] = sanitizeString(value);
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// Date validation helpers
|
||||
export function isValidDate(date: any): boolean {
|
||||
return date instanceof Date && !isNaN(date.getTime());
|
||||
}
|
||||
|
||||
export function validateDateRange(startDate: Date, endDate: Date): ValidationResult {
|
||||
if (!isValidDate(startDate) || !isValidDate(endDate)) {
|
||||
return { isValid: false, error: ERROR_MESSAGES.date };
|
||||
}
|
||||
|
||||
if (startDate >= endDate) {
|
||||
return { isValid: false, error: 'تاريخ البداية يجب أن يكون قبل تاريخ النهاية' };
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
// File validation helpers
|
||||
export function validateFileSize(file: File, maxSizeInMB: number): ValidationResult {
|
||||
const maxSizeInBytes = maxSizeInMB * 1024 * 1024;
|
||||
|
||||
if (file.size > maxSizeInBytes) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `حجم الملف يجب أن يكون أقل من ${maxSizeInMB} ميجابايت`
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
export function validateFileType(file: File, allowedTypes: string[]): ValidationResult {
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `نوع الملف غير مدعوم. الأنواع المدعومة: ${allowedTypes.join(', ')}`
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
// Array validation helpers
|
||||
export function validateArrayLength(array: any[], min?: number, max?: number): ValidationResult {
|
||||
if (min !== undefined && array.length < min) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `يجب اختيار ${min} عنصر على الأقل`
|
||||
};
|
||||
}
|
||||
|
||||
if (max !== undefined && array.length > max) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: `يجب اختيار ${max} عنصر كحد أقصى`
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
// Conditional validation
|
||||
export function validateConditional(
|
||||
value: any,
|
||||
condition: boolean,
|
||||
rules: ValidationRule
|
||||
): ValidationResult {
|
||||
if (!condition) {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
return validateField(value, rules);
|
||||
}
|
||||
342
app/lib/validation.ts
Normal file
342
app/lib/validation.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
import { VALIDATION, AUTH_LEVELS, USER_STATUS, TRANSMISSION_TYPES, FUEL_TYPES, USE_TYPES, PAYMENT_STATUS } from './constants';
|
||||
|
||||
// User validation
|
||||
export function validateUser(data: {
|
||||
name?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
authLevel?: number;
|
||||
status?: string;
|
||||
}) {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (data.name !== undefined) {
|
||||
if (!data.name || data.name.trim().length === 0) {
|
||||
errors.name = 'الاسم مطلوب';
|
||||
} else if (data.name.length > VALIDATION.MAX_NAME_LENGTH) {
|
||||
errors.name = `الاسم يجب أن يكون أقل من ${VALIDATION.MAX_NAME_LENGTH} حرف`;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.username !== undefined) {
|
||||
if (!data.username || data.username.trim().length === 0) {
|
||||
errors.username = 'اسم المستخدم مطلوب';
|
||||
} else if (data.username.length < 3) {
|
||||
errors.username = 'اسم المستخدم يجب أن يكون على الأقل 3 أحرف';
|
||||
} else if (!/^[a-zA-Z0-9_]+$/.test(data.username)) {
|
||||
errors.username = 'اسم المستخدم يجب أن يحتوي على أحرف وأرقام فقط';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.email !== undefined) {
|
||||
if (!data.email || data.email.trim().length === 0) {
|
||||
errors.email = 'البريد الإلكتروني مطلوب';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||
errors.email = 'البريد الإلكتروني غير صحيح';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.password !== undefined) {
|
||||
if (!data.password || data.password.length === 0) {
|
||||
errors.password = 'كلمة المرور مطلوبة';
|
||||
} else if (data.password.length < VALIDATION.MIN_PASSWORD_LENGTH) {
|
||||
errors.password = `كلمة المرور يجب أن تكون على الأقل ${VALIDATION.MIN_PASSWORD_LENGTH} أحرف`;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.authLevel !== undefined) {
|
||||
if (!Object.values(AUTH_LEVELS).includes(data.authLevel as any)) {
|
||||
errors.authLevel = 'مستوى الصلاحية غير صحيح';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.status !== undefined) {
|
||||
if (!Object.values(USER_STATUS).includes(data.status as any)) {
|
||||
errors.status = 'حالة المستخدم غير صحيحة';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// Customer validation
|
||||
export function validateCustomer(data: {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
}) {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (data.name !== undefined) {
|
||||
if (!data.name || data.name.trim().length === 0) {
|
||||
errors.name = 'اسم العميل مطلوب';
|
||||
} else if (data.name.length > VALIDATION.MAX_NAME_LENGTH) {
|
||||
errors.name = `الاسم يجب أن يكون أقل من ${VALIDATION.MAX_NAME_LENGTH} حرف`;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.phone && data.phone.trim().length > 0) {
|
||||
if (!/^[\+]?[0-9\s\-\(\)]+$/.test(data.phone)) {
|
||||
errors.phone = 'رقم الهاتف غير صحيح';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.email && data.email.trim().length > 0) {
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
|
||||
errors.email = 'البريد الإلكتروني غير صحيح';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// Vehicle validation
|
||||
export function validateVehicle(data: {
|
||||
plateNumber?: string;
|
||||
bodyType?: string;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
year?: number;
|
||||
transmission?: string;
|
||||
fuel?: string;
|
||||
cylinders?: number;
|
||||
engineDisplacement?: number;
|
||||
useType?: string;
|
||||
ownerId?: number;
|
||||
}) {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (data.plateNumber !== undefined) {
|
||||
if (!data.plateNumber || data.plateNumber.trim().length === 0) {
|
||||
errors.plateNumber = 'رقم اللوحة مطلوب';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.bodyType !== undefined) {
|
||||
if (!data.bodyType || data.bodyType.trim().length === 0) {
|
||||
errors.bodyType = 'نوع الهيكل مطلوب';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.manufacturer !== undefined) {
|
||||
if (!data.manufacturer || data.manufacturer.trim().length === 0) {
|
||||
errors.manufacturer = 'الشركة المصنعة مطلوبة';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.model !== undefined) {
|
||||
if (!data.model || data.model.trim().length === 0) {
|
||||
errors.model = 'الموديل مطلوب';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.year !== undefined) {
|
||||
if (!data.year || data.year < VALIDATION.MIN_YEAR || data.year > VALIDATION.MAX_YEAR) {
|
||||
errors.year = `السنة يجب أن تكون بين ${VALIDATION.MIN_YEAR} و ${VALIDATION.MAX_YEAR}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.transmission !== undefined) {
|
||||
const validTransmissions = TRANSMISSION_TYPES.map(t => t.value);
|
||||
if (!validTransmissions.includes(data.transmission as any)) {
|
||||
errors.transmission = 'نوع ناقل الحركة غير صحيح';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.fuel !== undefined) {
|
||||
const validFuels = FUEL_TYPES.map(f => f.value);
|
||||
if (!validFuels.includes(data.fuel as any)) {
|
||||
errors.fuel = 'نوع الوقود غير صحيح';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.cylinders !== undefined && data.cylinders !== null) {
|
||||
if (data.cylinders < 1 || data.cylinders > VALIDATION.MAX_CYLINDERS) {
|
||||
errors.cylinders = `عدد الأسطوانات يجب أن يكون بين 1 و ${VALIDATION.MAX_CYLINDERS}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.engineDisplacement !== undefined && data.engineDisplacement !== null) {
|
||||
if (data.engineDisplacement <= 0 || data.engineDisplacement > VALIDATION.MAX_ENGINE_DISPLACEMENT) {
|
||||
errors.engineDisplacement = `سعة المحرك يجب أن تكون بين 0.1 و ${VALIDATION.MAX_ENGINE_DISPLACEMENT}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.useType !== undefined) {
|
||||
const validUseTypes = USE_TYPES.map(u => u.value);
|
||||
if (!validUseTypes.includes(data.useType as any)) {
|
||||
errors.useType = 'نوع الاستخدام غير صحيح';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.ownerId !== undefined) {
|
||||
if (!data.ownerId || data.ownerId <= 0) {
|
||||
errors.ownerId = 'مالك المركبة مطلوب';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// Maintenance visit validation
|
||||
export function validateMaintenanceVisit(data: {
|
||||
vehicleId?: number;
|
||||
customerId?: number;
|
||||
maintenanceJobs?: Array<{typeId: number; job: string; notes?: string}>;
|
||||
description?: string;
|
||||
cost?: number;
|
||||
paymentStatus?: string;
|
||||
kilometers?: number;
|
||||
nextVisitDelay?: number;
|
||||
}) {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (data.vehicleId !== undefined) {
|
||||
if (!data.vehicleId || data.vehicleId <= 0) {
|
||||
errors.vehicleId = 'المركبة مطلوبة';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.customerId !== undefined) {
|
||||
if (!data.customerId || data.customerId <= 0) {
|
||||
errors.customerId = 'العميل مطلوب';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.maintenanceJobs !== undefined) {
|
||||
if (!data.maintenanceJobs || data.maintenanceJobs.length === 0) {
|
||||
errors.maintenanceJobs = 'يجب إضافة عمل صيانة واحد على الأقل';
|
||||
} else {
|
||||
// Validate each maintenance job
|
||||
const invalidJobs = data.maintenanceJobs.filter(job =>
|
||||
!job.typeId || job.typeId <= 0 || !job.job || job.job.trim().length === 0
|
||||
);
|
||||
if (invalidJobs.length > 0) {
|
||||
errors.maintenanceJobs = 'جميع أعمال الصيانة يجب أن تحتوي على نوع ووصف صحيح';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.description !== undefined) {
|
||||
if (!data.description || data.description.trim().length === 0) {
|
||||
errors.description = 'وصف الصيانة مطلوب';
|
||||
} else if (data.description.length > VALIDATION.MAX_DESCRIPTION_LENGTH) {
|
||||
errors.description = `الوصف يجب أن يكون أقل من ${VALIDATION.MAX_DESCRIPTION_LENGTH} حرف`;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.cost !== undefined) {
|
||||
if (data.cost === null || data.cost < VALIDATION.MIN_COST || data.cost > VALIDATION.MAX_COST) {
|
||||
errors.cost = `التكلفة يجب أن تكون بين ${VALIDATION.MIN_COST} و ${VALIDATION.MAX_COST}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.paymentStatus !== undefined) {
|
||||
if (!Object.values(PAYMENT_STATUS).includes(data.paymentStatus as any)) {
|
||||
errors.paymentStatus = 'حالة الدفع غير صحيحة';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.kilometers !== undefined) {
|
||||
if (data.kilometers === null || data.kilometers < 0) {
|
||||
errors.kilometers = 'عدد الكيلومترات يجب أن يكون رقم موجب';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.nextVisitDelay !== undefined) {
|
||||
if (!data.nextVisitDelay || ![1, 2, 3, 4].includes(data.nextVisitDelay)) {
|
||||
errors.nextVisitDelay = 'فترة الزيارة التالية يجب أن تكون 1، 2، 3، أو 4 أشهر';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// Expense validation
|
||||
export function validateExpense(data: {
|
||||
description?: string;
|
||||
category?: string;
|
||||
amount?: number;
|
||||
}) {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (data.description !== undefined) {
|
||||
if (!data.description || data.description.trim().length === 0) {
|
||||
errors.description = 'وصف المصروف مطلوب';
|
||||
} else if (data.description.length > VALIDATION.MAX_DESCRIPTION_LENGTH) {
|
||||
errors.description = `الوصف يجب أن يكون أقل من ${VALIDATION.MAX_DESCRIPTION_LENGTH} حرف`;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.category !== undefined) {
|
||||
if (!data.category || data.category.trim().length === 0) {
|
||||
errors.category = 'فئة المصروف مطلوبة';
|
||||
}
|
||||
}
|
||||
|
||||
if (data.amount !== undefined) {
|
||||
if (data.amount === null || data.amount <= VALIDATION.MIN_COST || data.amount > VALIDATION.MAX_COST) {
|
||||
errors.amount = `المبلغ يجب أن يكون بين ${VALIDATION.MIN_COST + 0.01} و ${VALIDATION.MAX_COST}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// Generic validation helpers
|
||||
export function isValidDate(date: any): date is Date {
|
||||
return date instanceof Date && !isNaN(date.getTime());
|
||||
}
|
||||
|
||||
export function isValidNumber(value: any): value is number {
|
||||
return typeof value === 'number' && !isNaN(value) && isFinite(value);
|
||||
}
|
||||
|
||||
export function isValidString(value: any, minLength = 1): value is string {
|
||||
return typeof value === 'string' && value.trim().length >= minLength;
|
||||
}
|
||||
|
||||
export function sanitizeString(value: string): string {
|
||||
return value.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'JOD',
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
export function formatDate(date: Date, format: string = 'dd/MM/yyyy'): string {
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
switch (format) {
|
||||
case 'dd/MM/yyyy':
|
||||
return `${day}/${month}/${year}`;
|
||||
case 'dd/MM/yyyy HH:mm':
|
||||
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
||||
default:
|
||||
return date.toLocaleDateString('ar-SA');
|
||||
}
|
||||
}
|
||||
368
app/lib/vehicle-management.server.ts
Normal file
368
app/lib/vehicle-management.server.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { prisma } from "./db.server";
|
||||
import type { Vehicle } from "@prisma/client";
|
||||
import type { CreateVehicleData, UpdateVehicleData, VehicleWithOwner, VehicleWithRelations } from "~/types/database";
|
||||
|
||||
// Get all vehicles with search and pagination
|
||||
export async function getVehicles(
|
||||
searchQuery?: string,
|
||||
page: number = 1,
|
||||
limit: number = 10,
|
||||
ownerId?: number,
|
||||
plateNumber?: string
|
||||
): Promise<{
|
||||
vehicles: VehicleWithOwner[];
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build where clause for search
|
||||
const whereClause: any = {};
|
||||
|
||||
if (ownerId) {
|
||||
whereClause.ownerId = ownerId;
|
||||
}
|
||||
|
||||
if (plateNumber) {
|
||||
whereClause.plateNumber = { contains: plateNumber.toLowerCase() };
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
whereClause.OR = [
|
||||
{ plateNumber: { contains: searchLower } },
|
||||
{ manufacturer: { contains: searchLower } },
|
||||
{ model: { contains: searchLower } },
|
||||
{ bodyType: { contains: searchLower } },
|
||||
{ owner: { name: { contains: searchLower } } },
|
||||
];
|
||||
}
|
||||
|
||||
const [vehicles, total] = await Promise.all([
|
||||
prisma.vehicle.findMany({
|
||||
where: whereClause,
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdDate: 'desc' },
|
||||
skip: offset,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.vehicle.count({ where: whereClause }),
|
||||
]);
|
||||
|
||||
return {
|
||||
vehicles,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
};
|
||||
}
|
||||
|
||||
// Get vehicle by ID with full relationships
|
||||
export async function getVehicleById(id: number): Promise<VehicleWithRelations | null> {
|
||||
return await prisma.vehicle.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
owner: true,
|
||||
maintenanceVisits: {
|
||||
orderBy: { visitDate: 'desc' },
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create new vehicle
|
||||
export async function createVehicle(
|
||||
vehicleData: CreateVehicleData
|
||||
): Promise<{ success: boolean; vehicle?: Vehicle; error?: string }> {
|
||||
try {
|
||||
// Check if vehicle with same plate number already exists
|
||||
const existingVehicle = await prisma.vehicle.findUnique({
|
||||
where: { plateNumber: vehicleData.plateNumber },
|
||||
});
|
||||
|
||||
if (existingVehicle) {
|
||||
return { success: false, error: "رقم اللوحة موجود بالفعل" };
|
||||
}
|
||||
|
||||
// Check if owner exists
|
||||
const owner = await prisma.customer.findUnique({
|
||||
where: { id: vehicleData.ownerId },
|
||||
});
|
||||
|
||||
if (!owner) {
|
||||
return { success: false, error: "المالك غير موجود" };
|
||||
}
|
||||
|
||||
// Create vehicle
|
||||
const vehicle = await prisma.vehicle.create({
|
||||
data: {
|
||||
plateNumber: vehicleData.plateNumber.trim(),
|
||||
bodyType: vehicleData.bodyType.trim(),
|
||||
manufacturer: vehicleData.manufacturer.trim(),
|
||||
model: vehicleData.model.trim(),
|
||||
trim: vehicleData.trim?.trim() || null,
|
||||
year: vehicleData.year,
|
||||
transmission: vehicleData.transmission,
|
||||
fuel: vehicleData.fuel,
|
||||
cylinders: vehicleData.cylinders || null,
|
||||
engineDisplacement: vehicleData.engineDisplacement || null,
|
||||
useType: vehicleData.useType,
|
||||
ownerId: vehicleData.ownerId,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, vehicle };
|
||||
} catch (error) {
|
||||
console.error("Error creating vehicle:", error);
|
||||
return { success: false, error: "حدث خطأ أثناء إنشاء المركبة" };
|
||||
}
|
||||
}
|
||||
|
||||
// Update vehicle
|
||||
export async function updateVehicle(
|
||||
id: number,
|
||||
vehicleData: UpdateVehicleData
|
||||
): Promise<{ success: boolean; vehicle?: Vehicle; error?: string }> {
|
||||
try {
|
||||
// Check if vehicle exists
|
||||
const existingVehicle = await prisma.vehicle.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingVehicle) {
|
||||
return { success: false, error: "المركبة غير موجودة" };
|
||||
}
|
||||
|
||||
// Check for plate number conflicts with other vehicles
|
||||
if (vehicleData.plateNumber) {
|
||||
const conflictVehicle = await prisma.vehicle.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{ id: { not: id } },
|
||||
{ plateNumber: vehicleData.plateNumber },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (conflictVehicle) {
|
||||
return { success: false, error: "رقم اللوحة موجود بالفعل" };
|
||||
}
|
||||
}
|
||||
|
||||
// Check if new owner exists (if changing owner)
|
||||
if (vehicleData.ownerId && vehicleData.ownerId !== existingVehicle.ownerId) {
|
||||
const owner = await prisma.customer.findUnique({
|
||||
where: { id: vehicleData.ownerId },
|
||||
});
|
||||
|
||||
if (!owner) {
|
||||
return { success: false, error: "المالك الجديد غير موجود" };
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: any = {};
|
||||
if (vehicleData.plateNumber !== undefined) updateData.plateNumber = vehicleData.plateNumber.trim();
|
||||
if (vehicleData.bodyType !== undefined) updateData.bodyType = vehicleData.bodyType.trim();
|
||||
if (vehicleData.manufacturer !== undefined) updateData.manufacturer = vehicleData.manufacturer.trim();
|
||||
if (vehicleData.model !== undefined) updateData.model = vehicleData.model.trim();
|
||||
if (vehicleData.trim !== undefined) updateData.trim = vehicleData.trim?.trim() || null;
|
||||
if (vehicleData.year !== undefined) updateData.year = vehicleData.year;
|
||||
if (vehicleData.transmission !== undefined) updateData.transmission = vehicleData.transmission;
|
||||
if (vehicleData.fuel !== undefined) updateData.fuel = vehicleData.fuel;
|
||||
if (vehicleData.cylinders !== undefined) updateData.cylinders = vehicleData.cylinders || null;
|
||||
if (vehicleData.engineDisplacement !== undefined) updateData.engineDisplacement = vehicleData.engineDisplacement || null;
|
||||
if (vehicleData.useType !== undefined) updateData.useType = vehicleData.useType;
|
||||
if (vehicleData.ownerId !== undefined) updateData.ownerId = vehicleData.ownerId;
|
||||
if (vehicleData.lastVisitDate !== undefined) updateData.lastVisitDate = vehicleData.lastVisitDate;
|
||||
if (vehicleData.suggestedNextVisitDate !== undefined) updateData.suggestedNextVisitDate = vehicleData.suggestedNextVisitDate;
|
||||
|
||||
// Update vehicle
|
||||
const vehicle = await prisma.vehicle.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return { success: true, vehicle };
|
||||
} catch (error) {
|
||||
console.error("Error updating vehicle:", error);
|
||||
return { success: false, error: "حدث خطأ أثناء تحديث المركبة" };
|
||||
}
|
||||
}
|
||||
|
||||
// Delete vehicle with relationship handling
|
||||
export async function deleteVehicle(
|
||||
id: number
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
// Check if vehicle exists
|
||||
const existingVehicle = await prisma.vehicle.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
maintenanceVisits: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingVehicle) {
|
||||
return { success: false, error: "المركبة غير موجودة" };
|
||||
}
|
||||
|
||||
// Check if vehicle has maintenance visits
|
||||
if (existingVehicle.maintenanceVisits.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `لا يمكن حذف المركبة لأنها تحتوي على ${existingVehicle.maintenanceVisits.length} زيارة صيانة. يرجى حذف الزيارات أولاً`
|
||||
};
|
||||
}
|
||||
|
||||
// Delete vehicle
|
||||
await prisma.vehicle.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Error deleting vehicle:", error);
|
||||
return { success: false, error: "حدث خطأ أثناء حذف المركبة" };
|
||||
}
|
||||
}
|
||||
|
||||
// Get vehicles for dropdown/select options
|
||||
export async function getVehiclesForSelect(ownerId?: number): Promise<{
|
||||
id: number;
|
||||
plateNumber: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
year: number;
|
||||
}[]> {
|
||||
const whereClause = ownerId ? { ownerId } : {};
|
||||
|
||||
return await prisma.vehicle.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
plateNumber: true,
|
||||
manufacturer: true,
|
||||
model: true,
|
||||
year: true,
|
||||
},
|
||||
orderBy: { plateNumber: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
// Get vehicle statistics
|
||||
export async function getVehicleStats(vehicleId: number): Promise<{
|
||||
totalVisits: number;
|
||||
totalSpent: number;
|
||||
lastVisitDate?: Date;
|
||||
nextSuggestedVisitDate?: Date;
|
||||
averageVisitCost: number;
|
||||
} | null> {
|
||||
const vehicle = await prisma.vehicle.findUnique({
|
||||
where: { id: vehicleId },
|
||||
include: {
|
||||
maintenanceVisits: {
|
||||
select: {
|
||||
cost: true,
|
||||
visitDate: true,
|
||||
},
|
||||
orderBy: { visitDate: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!vehicle) return null;
|
||||
|
||||
const totalSpent = vehicle.maintenanceVisits.reduce((sum, visit) => sum + visit.cost, 0);
|
||||
const averageVisitCost = vehicle.maintenanceVisits.length > 0
|
||||
? totalSpent / vehicle.maintenanceVisits.length
|
||||
: 0;
|
||||
const lastVisitDate = vehicle.maintenanceVisits.length > 0
|
||||
? vehicle.maintenanceVisits[0].visitDate
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
totalVisits: vehicle.maintenanceVisits.length,
|
||||
totalSpent,
|
||||
lastVisitDate,
|
||||
nextSuggestedVisitDate: vehicle.suggestedNextVisitDate || undefined,
|
||||
averageVisitCost,
|
||||
};
|
||||
}
|
||||
|
||||
// Search vehicles by plate number or manufacturer (for autocomplete)
|
||||
export async function searchVehicles(query: string, limit: number = 10): Promise<{
|
||||
id: number;
|
||||
plateNumber: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
year: number;
|
||||
owner: { id: number; name: string; };
|
||||
}[]> {
|
||||
if (!query || query.trim().length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const searchLower = query.toLowerCase();
|
||||
|
||||
return await prisma.vehicle.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ plateNumber: { contains: searchLower } },
|
||||
{ manufacturer: { contains: searchLower } },
|
||||
{ model: { contains: searchLower } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
plateNumber: true,
|
||||
manufacturer: true,
|
||||
model: true,
|
||||
year: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { plateNumber: 'asc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
// Get vehicles by owner ID
|
||||
export async function getVehiclesByOwner(ownerId: number): Promise<VehicleWithOwner[]> {
|
||||
return await prisma.vehicle.findMany({
|
||||
where: { ownerId },
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdDate: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export async function getMaintenanceVisitsByVehicle(vehicleId: number) {
|
||||
const visits = await prisma.maintenanceVisit.findMany({
|
||||
where: { vehicleId: vehicleId },
|
||||
orderBy: { createdDate: 'desc' },
|
||||
});
|
||||
return visits
|
||||
}
|
||||
Reference in New Issue
Block a user