updates
This commit is contained in:
@@ -313,54 +313,77 @@ class HRService {
|
|||||||
// ========== LEAVES ==========
|
// ========== LEAVES ==========
|
||||||
|
|
||||||
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 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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
async approveLeave(id: string, approvedBy: string, userId: string) {
|
||||||
const leave = await prisma.leave.update({
|
const leave = await prisma.leave.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|||||||
@@ -144,19 +144,65 @@ export class PortalController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
async submitLeaveRequest(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
const data = {
|
const body = { ...req.body };
|
||||||
...req.body,
|
const leaveType = String(body.leaveType || '').toUpperCase();
|
||||||
startDate: new Date(req.body.startDate),
|
|
||||||
endDate: new Date(req.body.endDate),
|
let startDate: Date;
|
||||||
};
|
let endDate: Date;
|
||||||
const leave = await portalService.submitLeaveRequest(req.user?.employeeId, data, req.user!.id);
|
|
||||||
res.status(201).json(ResponseFormatter.success(leave, 'تم إرسال طلب الإجازة - Leave request submitted'));
|
if (leaveType === 'HOURLY' && body.leaveDate && body.startTime && body.endTime) {
|
||||||
} catch (error) {
|
startDate = new Date(`${body.leaveDate}T${body.startTime}:00+03:00`);
|
||||||
next(error);
|
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) {
|
async getMyPurchaseRequests(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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 |
@@ -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',
|
||||||
|
|||||||
@@ -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,9 +106,12 @@ 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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,8 +278,16 @@ 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: {
|
||||||
const response = await api.post('/hr/portal/leaves', 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)
|
||||||
return response.data.data
|
return response.data.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user