diff --git a/backend/src/modules/hr/hr.service.ts b/backend/src/modules/hr/hr.service.ts index 07b0764..10d6fb2 100644 --- a/backend/src/modules/hr/hr.service.ts +++ b/backend/src/modules/hr/hr.service.ts @@ -313,54 +313,77 @@ class HRService { // ========== LEAVES ========== async createLeaveRequest(data: any, userId: string) { - const allowedLeaveTypes = ['ANNUAL', 'HOURLY']; + const allowedLeaveTypes = ['ANNUAL', 'HOURLY']; + const normalizedLeaveType = String(data.leaveType || '').toUpperCase(); - if (!allowedLeaveTypes.includes(String(data.leaveType || '').toUpperCase())) { - throw new AppError(400, 'نوع الإجازة غير مدعوم - Only ANNUAL and HOURLY leave types are allowed'); - } - - const normalizedLeaveType = String(data.leaveType).toUpperCase(); - const days = this.calculateLeaveDays(data.startDate, data.endDate); - const startDate = new Date(data.startDate); - const year = startDate.getFullYear(); - - const ent = await prisma.leaveEntitlement.findUnique({ - where: { - employeeId_year_leaveType: { - employeeId: data.employeeId, - year, - leaveType: normalizedLeaveType, - }, - }, - }); - - if (ent) { - const available = ent.totalDays + ent.carriedOver - ent.usedDays; - if (days > available) { - throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`); + if (!allowedLeaveTypes.includes(normalizedLeaveType)) { + throw new AppError(400, 'نوع الإجازة غير مدعوم - Only ANNUAL and HOURLY leave types are allowed'); } + + const startDate = new Date(data.startDate); + const endDate = new Date(data.endDate); + + if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) { + throw new AppError(400, 'تاريخ أو وقت الإجازة غير صالح'); + } + + const isInvalidRange = normalizedLeaveType === 'HOURLY' + ? endDate <= startDate + : endDate < startDate; + + if (isInvalidRange) { + throw new AppError(400, 'وقت/تاريخ النهاية يجب أن يكون بعد البداية'); + } + + const days = normalizedLeaveType === 'HOURLY' + ? 0 + : this.calculateLeaveDays(startDate, endDate); + + const year = startDate.getFullYear(); + + if (normalizedLeaveType !== 'HOURLY') { + const ent = await prisma.leaveEntitlement.findUnique({ + where: { + employeeId_year_leaveType: { + employeeId: data.employeeId, + year, + leaveType: normalizedLeaveType, + }, + }, + }); + + if (ent) { + const available = ent.totalDays + ent.carriedOver - ent.usedDays; + if (days > available) { + throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`); + } + } + } + + const leave = await prisma.leave.create({ + data: { + employeeId: data.employeeId, + leaveType: normalizedLeaveType, + startDate, + endDate, + days, + reason: data.reason || undefined, + }, + include: { + employee: true, + }, + }); + + await AuditLogger.log({ + entityType: 'LEAVE', + entityId: leave.id, + action: 'CREATE', + userId, + }); + + return leave; } - const leave = await prisma.leave.create({ - data: { - ...data, - leaveType: normalizedLeaveType, - days, - }, - include: { - employee: true, - }, - }); - - await AuditLogger.log({ - entityType: 'LEAVE', - entityId: leave.id, - action: 'CREATE', - userId, - }); - - return leave; -} async approveLeave(id: string, approvedBy: string, userId: string) { const leave = await prisma.leave.update({ where: { id }, diff --git a/backend/src/modules/hr/portal.controller.ts b/backend/src/modules/hr/portal.controller.ts index ec45537..7a136a3 100644 --- a/backend/src/modules/hr/portal.controller.ts +++ b/backend/src/modules/hr/portal.controller.ts @@ -144,19 +144,65 @@ export class PortalController { } } - async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) { - try { - const data = { - ...req.body, - startDate: new Date(req.body.startDate), - endDate: new Date(req.body.endDate), - }; - const leave = await portalService.submitLeaveRequest(req.user?.employeeId, data, req.user!.id); - res.status(201).json(ResponseFormatter.success(leave, 'تم إرسال طلب الإجازة - Leave request submitted')); - } catch (error) { - next(error); + async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) { + try { + const body = { ...req.body }; + const leaveType = String(body.leaveType || '').toUpperCase(); + + let startDate: Date; + let endDate: Date; + + if (leaveType === 'HOURLY' && body.leaveDate && body.startTime && body.endTime) { + startDate = new Date(`${body.leaveDate}T${body.startTime}:00+03:00`); + endDate = new Date(`${body.leaveDate}T${body.endTime}:00+03:00`); + } else { + startDate = new Date(body.startDate); + endDate = new Date(body.endDate); } + + if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) { + return res.status(400).json({ + success: false, + message: 'تاريخ أو وقت الإجازة غير صالح - Invalid leave date or time', + }); + } + + const isInvalidRange = leaveType === 'HOURLY' + ? endDate <= startDate + : endDate < startDate; + + if (isInvalidRange) { + return res.status(400).json({ + success: false, + message: 'وقت/تاريخ النهاية يجب أن يكون بعد البداية - End date/time must be after start date/time', + }); + } + + const data = { + leaveType, + startDate, + endDate, + reason: body.reason || undefined, + }; + + const leave = await portalService.submitLeaveRequest( + req.user?.employeeId, + data, + req.user!.id + ); + + res + .status(201) + .json( + ResponseFormatter.success( + leave, + 'تم إرسال طلب الإجازة - Leave request submitted' + ) + ); + } catch (error) { + next(error); } +} async getMyPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) { try { diff --git a/docker-compose.yml b/docker-compose.yml index 70821aa..79807de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,7 @@ services: JWT_SECRET: ${JWT_SECRET:-z-crm-jwt-secret-change-in-production-NOW} JWT_EXPIRES_IN: 7d JWT_REFRESH_EXPIRES_IN: 30d + MAX_FILE_SIZE: 52428800 BCRYPT_ROUNDS: 10 CORS_ORIGIN: https://zerp.atmata-group.com,http://zerp.atmata-group.com,http://localhost:3000,http://37.60.249.71:3000 depends_on: diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico index 3239bf6..c770706 100644 Binary files a/frontend/public/favicon.ico and b/frontend/public/favicon.ico differ diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 4d44ce8..6d2d0e3 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -127,12 +127,10 @@ function DashboardContent() { if (notification.entityType === 'EXPENSE_CLAIM') { if (notification.entityId) { - // إشعار المدير: بانتظار الموافقة if (notification.type === 'EXPENSE_CLAIM_SUBMITTED') { return `/portal/managed-expense-claims?claimId=${notification.entityId}`; } - // إشعار الموظف: تم الإرسال / تمت الموافقة / تم الرفض if ( notification.type === 'EXPENSE_CLAIM_CREATED' || notification.type === 'EXPENSE_CLAIM_APPROVED' || @@ -266,7 +264,7 @@ function DashboardContent() { color: 'bg-emerald-500', href: '/suppliers', description: 'إدارة الموردين وبيانات التواصل والاعتماد', - permission: 'contacts' + permission: 'suppliers' }, { id: 'crm', diff --git a/frontend/src/app/portal/leave/page.tsx b/frontend/src/app/portal/leave/page.tsx index 6399339..ce7875a 100644 --- a/frontend/src/app/portal/leave/page.tsx +++ b/frontend/src/app/portal/leave/page.tsx @@ -24,6 +24,27 @@ const STATUS_MAP: Record = { REJECTED: { label: 'مرفوضة', color: 'bg-red-100 text-red-800' }, } +const COMPANY_TIME_ZONE = 'Asia/Riyadh' +const COMPANY_UTC_OFFSET = '+03:00' + +const toCompanyDateTime = (date: string, time: string) => { + return `${date}T${time}:00${COMPANY_UTC_OFFSET}` +} + +const formatCompanyTime = (value: string) => { + return new Date(value).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + timeZone: COMPANY_TIME_ZONE, + }) +} + +const formatCompanyDate = (value: string) => { + return new Date(value).toLocaleDateString('ar-SA', { + timeZone: COMPANY_TIME_ZONE, + }) +} + export default function PortalLeavePage() { const [leaveBalance, setLeaveBalance] = useState([]) const [leaves, setLeaves] = useState([]) @@ -53,10 +74,6 @@ export default function PortalLeavePage() { } useEffect(() => load(), []) - const toCompanyDateTime = (date: string, time: string) => { - return `${date}T${time}:00+03:00` - } - const handleSubmit = (e: React.FormEvent) => { e.preventDefault() @@ -89,9 +106,12 @@ export default function PortalLeavePage() { return } - payload.startDate = `${form.leaveDate}T${form.startTime}:00+03:00` - payload.endDate = `${form.leaveDate}T${form.endTime}:00+03:00` - } + payload.leaveDate = form.leaveDate + payload.startTime = form.startTime + payload.endTime = form.endTime + payload.startDate = toCompanyDateTime(form.leaveDate, form.startTime) + payload.endDate = toCompanyDateTime(form.leaveDate, form.endTime) + } setSubmitting(true) @@ -110,7 +130,15 @@ export default function PortalLeavePage() { toast.success('تم إرسال طلب الإجازة') load() }) - .catch(() => toast.error('فشل إرسال الطلب')) + .catch((err: any) => { + const message = + err.response?.data?.message || + err.response?.data?.error || + 'فشل إرسال الطلب' + + console.error('Leave request error:', err.response?.data || err) + toast.error(message) + }) .finally(() => setSubmitting(false)) } @@ -165,12 +193,12 @@ export default function PortalLeavePage() {

{l.leaveType === 'ANNUAL' ? 'سنوية' : 'ساعية'} -{' '} {l.leaveType === 'HOURLY' - ? `${new Date(l.startDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${new Date(l.endDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` + ? `${formatCompanyTime(l.startDate)} - ${formatCompanyTime(l.endDate)}` : `${l.days} يوم`}

- {new Date(l.startDate).toLocaleDateString('ar-SA')} - {new Date(l.endDate).toLocaleDateString('ar-SA')} + {formatCompanyDate(l.startDate)} - {formatCompanyDate(l.endDate)}

diff --git a/frontend/src/app/portal/managed-leaves/page.tsx b/frontend/src/app/portal/managed-leaves/page.tsx index 6480cfb..4c27ae3 100644 --- a/frontend/src/app/portal/managed-leaves/page.tsx +++ b/frontend/src/app/portal/managed-leaves/page.tsx @@ -8,6 +8,23 @@ import { toast } from 'react-hot-toast' import { CheckCircle2, XCircle, Calendar, User, ArrowLeft } from 'lucide-react' import Link from 'next/link' + +const COMPANY_TIME_ZONE = 'Asia/Riyadh' + +const formatCompanyTime = (value: string) => { + return new Date(value).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + timeZone: COMPANY_TIME_ZONE, + }) +} + +const formatCompanyDateTime = (value: string) => { + return new Date(value).toLocaleString('ar-SA', { + timeZone: COMPANY_TIME_ZONE, + }) +} + export default function ManagedLeavesPage() { const { hasPermission } = useAuth() const [leaves, setLeaves] = useState([]) @@ -136,15 +153,15 @@ export default function ManagedLeavesPage() {
-

{new Date(leave.startDate).toLocaleString()}

-

{new Date(leave.endDate).toLocaleString()}

+

{formatCompanyDateTime(leave.startDate)}

+

{formatCompanyDateTime(leave.endDate)}

{leave.leaveType === 'HOURLY' - ? `${new Date(leave.startDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} - ${new Date(leave.endDate).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` + ? `${formatCompanyTime(leave.startDate)} - ${formatCompanyTime(leave.endDate)}` : `${leave.days} يوم`} diff --git a/frontend/src/app/suppliers/page.tsx b/frontend/src/app/suppliers/page.tsx index 88b3811..374cff2 100644 --- a/frontend/src/app/suppliers/page.tsx +++ b/frontend/src/app/suppliers/page.tsx @@ -318,14 +318,6 @@ function SuppliersContent() { - -
-
- -

غير مصرح بالوصول

-

لا تملك صلاحية عرض إدارة الموردين.

-
-
) } diff --git a/frontend/src/lib/api/portal.ts b/frontend/src/lib/api/portal.ts index 06df5c6..4c76d92 100644 --- a/frontend/src/lib/api/portal.ts +++ b/frontend/src/lib/api/portal.ts @@ -278,8 +278,16 @@ export const portalAPI = { return response.data.data || [] }, - submitLeaveRequest: async (data: { leaveType: string; startDate: string; endDate: string; reason?: string }): Promise => { - const response = await api.post('/hr/portal/leaves', data) + submitLeaveRequest: async (data: { + leaveType: string + startDate: string + endDate: string + leaveDate?: string + startTime?: string + endTime?: string + reason?: string + }): Promise => { + const response = await api.post('/hr/portal/leaves', data) return response.data.data },