This commit is contained in:
Aya
2026-05-07 15:21:10 +03:00
parent 9e5dd47a2f
commit e01e351713
9 changed files with 194 additions and 81 deletions

View File

@@ -314,16 +314,34 @@ class HRService {
async createLeaveRequest(data: any, userId: string) { 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())) { if (!allowedLeaveTypes.includes(normalizedLeaveType)) {
throw new AppError(400, 'نوع الإجازة غير مدعوم - Only ANNUAL and HOURLY leave types are allowed'); 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 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(); const year = startDate.getFullYear();
if (normalizedLeaveType !== 'HOURLY') {
const ent = await prisma.leaveEntitlement.findUnique({ const ent = await prisma.leaveEntitlement.findUnique({
where: { where: {
employeeId_year_leaveType: { employeeId_year_leaveType: {
@@ -340,12 +358,16 @@ class HRService {
throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`); throw new AppError(400, `رصيد الإجازة غير كافٍ - Insufficient leave balance. Available: ${available}, Requested: ${days}`);
} }
} }
}
const leave = await prisma.leave.create({ const leave = await prisma.leave.create({
data: { data: {
...data, employeeId: data.employeeId,
leaveType: normalizedLeaveType, leaveType: normalizedLeaveType,
startDate,
endDate,
days, days,
reason: data.reason || undefined,
}, },
include: { include: {
employee: true, employee: true,
@@ -361,6 +383,7 @@ class HRService {
return leave; return leave;
} }
async approveLeave(id: string, approvedBy: string, userId: string) { async approveLeave(id: string, approvedBy: string, userId: string) {
const leave = await prisma.leave.update({ const leave = await prisma.leave.update({
where: { id }, where: { id },

View File

@@ -146,13 +146,59 @@ export class PortalController {
async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) { async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
try { 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 = { const data = {
...req.body, leaveType,
startDate: new Date(req.body.startDate), startDate,
endDate: new Date(req.body.endDate), 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')); const leave = await portalService.submitLeaveRequest(
req.user?.employeeId,
data,
req.user!.id
);
res
.status(201)
.json(
ResponseFormatter.success(
leave,
'تم إرسال طلب الإجازة - Leave request submitted'
)
);
} catch (error) { } catch (error) {
next(error); next(error);
} }

View File

@@ -34,6 +34,7 @@ services:
JWT_SECRET: ${JWT_SECRET:-z-crm-jwt-secret-change-in-production-NOW} JWT_SECRET: ${JWT_SECRET:-z-crm-jwt-secret-change-in-production-NOW}
JWT_EXPIRES_IN: 7d JWT_EXPIRES_IN: 7d
JWT_REFRESH_EXPIRES_IN: 30d JWT_REFRESH_EXPIRES_IN: 30d
MAX_FILE_SIZE: 52428800
BCRYPT_ROUNDS: 10 BCRYPT_ROUNDS: 10
CORS_ORIGIN: https://zerp.atmata-group.com,http://zerp.atmata-group.com,http://localhost:3000,http://37.60.249.71:3000 CORS_ORIGIN: https://zerp.atmata-group.com,http://zerp.atmata-group.com,http://localhost:3000,http://37.60.249.71:3000
depends_on: depends_on:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -127,12 +127,10 @@ function DashboardContent() {
if (notification.entityType === 'EXPENSE_CLAIM') { if (notification.entityType === 'EXPENSE_CLAIM') {
if (notification.entityId) { if (notification.entityId) {
// إشعار المدير: بانتظار الموافقة
if (notification.type === 'EXPENSE_CLAIM_SUBMITTED') { if (notification.type === 'EXPENSE_CLAIM_SUBMITTED') {
return `/portal/managed-expense-claims?claimId=${notification.entityId}`; return `/portal/managed-expense-claims?claimId=${notification.entityId}`;
} }
// إشعار الموظف: تم الإرسال / تمت الموافقة / تم الرفض
if ( if (
notification.type === 'EXPENSE_CLAIM_CREATED' || notification.type === 'EXPENSE_CLAIM_CREATED' ||
notification.type === 'EXPENSE_CLAIM_APPROVED' || notification.type === 'EXPENSE_CLAIM_APPROVED' ||
@@ -266,7 +264,7 @@ function DashboardContent() {
color: 'bg-emerald-500', color: 'bg-emerald-500',
href: '/suppliers', href: '/suppliers',
description: 'إدارة الموردين وبيانات التواصل والاعتماد', description: 'إدارة الموردين وبيانات التواصل والاعتماد',
permission: 'contacts' permission: 'suppliers'
}, },
{ {
id: 'crm', id: 'crm',

View File

@@ -24,6 +24,27 @@ const STATUS_MAP: Record<string, { label: string; color: string }> = {
REJECTED: { label: 'مرفوضة', color: 'bg-red-100 text-red-800' }, 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() { export default function PortalLeavePage() {
const [leaveBalance, setLeaveBalance] = useState<any[]>([]) const [leaveBalance, setLeaveBalance] = useState<any[]>([])
const [leaves, setLeaves] = useState<any[]>([]) const [leaves, setLeaves] = useState<any[]>([])
@@ -53,10 +74,6 @@ export default function PortalLeavePage() {
} }
useEffect(() => load(), []) useEffect(() => load(), [])
const toCompanyDateTime = (date: string, time: string) => {
return `${date}T${time}:00+03:00`
}
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
@@ -89,8 +106,11 @@ export default function PortalLeavePage() {
return return
} }
payload.startDate = `${form.leaveDate}T${form.startTime}:00+03:00` payload.leaveDate = form.leaveDate
payload.endDate = `${form.leaveDate}T${form.endTime}:00+03:00` payload.startTime = form.startTime
payload.endTime = form.endTime
payload.startDate = toCompanyDateTime(form.leaveDate, form.startTime)
payload.endDate = toCompanyDateTime(form.leaveDate, form.endTime)
} }
setSubmitting(true) setSubmitting(true)
@@ -110,7 +130,15 @@ export default function PortalLeavePage() {
toast.success('تم إرسال طلب الإجازة') toast.success('تم إرسال طلب الإجازة')
load() 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)) .finally(() => setSubmitting(false))
} }
@@ -165,12 +193,12 @@ export default function PortalLeavePage() {
<p className="font-medium"> <p className="font-medium">
{l.leaveType === 'ANNUAL' ? 'سنوية' : 'ساعية'} -{' '} {l.leaveType === 'ANNUAL' ? 'سنوية' : 'ساعية'} -{' '}
{l.leaveType === 'HOURLY' {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} يوم`} : `${l.days} يوم`}
</p> </p>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
{new Date(l.startDate).toLocaleDateString('ar-SA')} - {new Date(l.endDate).toLocaleDateString('ar-SA')} {formatCompanyDate(l.startDate)} - {formatCompanyDate(l.endDate)}
</p> </p>
</div> </div>

View File

@@ -8,6 +8,23 @@ import { toast } from 'react-hot-toast'
import { CheckCircle2, XCircle, Calendar, User, ArrowLeft } from 'lucide-react' import { CheckCircle2, XCircle, Calendar, User, ArrowLeft } from 'lucide-react'
import Link from 'next/link' 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() { export default function ManagedLeavesPage() {
const { hasPermission } = useAuth() const { hasPermission } = useAuth()
const [leaves, setLeaves] = useState<ManagedLeave[]>([]) const [leaves, setLeaves] = useState<ManagedLeave[]>([])
@@ -136,15 +153,15 @@ export default function ManagedLeavesPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-gray-400" /> <Calendar className="h-4 w-4 text-gray-400" />
<div> <div>
<p>{new Date(leave.startDate).toLocaleString()}</p> <p>{formatCompanyDateTime(leave.startDate)}</p>
<p>{new Date(leave.endDate).toLocaleString()}</p> <p>{formatCompanyDateTime(leave.endDate)}</p>
</div> </div>
</div> </div>
</td> </td>
<td className="px-6 py-4 text-gray-900"> <td className="px-6 py-4 text-gray-900">
{leave.leaveType === 'HOURLY' {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} يوم`} : `${leave.days} يوم`}
</td> </td>

View File

@@ -318,14 +318,6 @@ function SuppliersContent() {
</div> </div>
</div> </div>
</header> </header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-10 text-center">
<Shield className="h-12 w-12 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-bold text-gray-900 mb-2">غير مصرح بالوصول</h2>
<p className="text-gray-600">لا تملك صلاحية عرض إدارة الموردين.</p>
</div>
</main>
</div> </div>
) )
} }

View File

@@ -278,7 +278,15 @@ export const portalAPI = {
return response.data.data || [] return response.data.data || []
}, },
submitLeaveRequest: async (data: { leaveType: string; startDate: string; endDate: string; reason?: string }): Promise<Leave> => { submitLeaveRequest: async (data: {
leaveType: string
startDate: string
endDate: string
leaveDate?: string
startTime?: string
endTime?: string
reason?: string
}): Promise<Leave> => {
const response = await api.post('/hr/portal/leaves', data) const response = await api.post('/hr/portal/leaves', data)
return response.data.data return response.data.data
}, },