PSTW_CentralizeSystem/Areas/OTcalculate/Views/ApprovalDashboard/OtReview.cshtml
2025-08-06 11:34:32 +08:00

1373 lines
69 KiB
Plaintext

@{
ViewData["Title"] = "OT Details Review";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<style>
.table-container {
background-color: white;
border-radius: 15px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
padding: 25px;
margin-bottom: 20px;
}
.table th, .table td {
vertical-align: middle;
text-align: center;
font-size: 14px;
}
.header-green {
background-color: #d9ead3 !important;
}
.header-blue {
background-color: #cfe2f3 !important;
}
.header-orange {
background-color: #fce5cd !important;
}
.file-info {
margin-top: 10px;
font-size: 14px;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
}
.file-info a {
color: #007bff;
text-decoration: none;
font-weight: 500;
margin-left: 8px;
}
.file-info a:hover {
text-decoration: underline;
color: #0056b3;
}
.wrap-text .description-preview {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
max-width: 200px;
}
.wrap-text .description-preview.expanded {
white-space: normal;
overflow: visible;
}
.btn-disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
<div id="reviewApp" style="max-width: 1300px; margin: auto; font-size: 13px;">
<div class="table-container table-responsive">
<div style="margin-bottom: 15px;">
<strong>Employee Name:</strong> {{ userInfo.fullName }}<br />
<strong>Department:</strong> {{ userInfo.departmentName || 'N/A' }}<br />
<strong>Flexi Hour:</strong> {{ userInfo.flexiHour || 'N/A' }}<br />
<template v-if="userInfo.filePath">
<div class="file-info">
<strong>Uploaded File:</strong>
<a :href="userInfo.filePath" target="_blank">
View Uploaded File
</a>
</div>
</template>
</div>
<table class="table table-bordered table-sm table-striped">
<thead>
<tr>
<th class="header-orange" rowspan="2" v-if="!isApproverRole(['HoU', 'HoD', 'Manager'])">Basic Salary<br>(RM)</th>
<th class="header-orange" rowspan="2" v-if="!isApproverRole(['HoU', 'HoD', 'Manager'])">ORP</th>
<th class="header-orange" rowspan="2" v-if="!isApproverRole(['HoU', 'HoD', 'Manager'])">HRP</th>
<th class="header-green text-nowrap" rowspan="2">Date</th>
<th class="header-blue" colspan="3">Office Hour</th>
<th class="header-blue" colspan="3">After Office Hour</th>
<th class="header-green" rowspan="2">OT Hrs<br>(Office Hour)</th>
<th class="header-green" rowspan="2">OT Hrs<br>(After Office Hour)</th>
<th class="header-blue" colspan="1">Normal Day</th>
<th class="header-blue" colspan="3">Off Day</th>
<th class="header-blue" colspan="3">Rest Day</th>
<th class="header-blue" colspan="2">Public Holiday</th>
<th class="header-green" rowspan="2">Total OT Hrs</th>
<th class="header-green" rowspan="2">Total OT Hrs<br>ND & OD</th>
<th class="header-green" rowspan="2">Total OT Hrs<br>RD</th>
<th class="header-green" rowspan="2">Total OT Hrs<br>PH</th>
<th class="header-green" rowspan="2" v-if="!isApproverRole(['HoU', 'HoD', 'Manager'])">OT Amt (RM)</th>
<th class="header-orange" rowspan="2" v-if="showStationColumn">Station</th>
<th class="header-blue" rowspan="2">Description</th>
<th class="header-green" rowspan="2">Action</th>
</tr>
<tr>
<th class="header-blue">From</th>
<th class="header-blue">To</th>
<th class="header-blue">Break</th>
<th class="header-blue">From</th>
<th class="header-blue">To</th>
<th class="header-blue">Break</th>
<th class="header-blue">ND OT after office hrs</th>
<th class="header-blue">OD Within office hrs &lt; 4hrs</th>
<th class="header-blue">OD Within office hrs &gt; 4hrs &lt; 8 hrs</th>
<th class="header-blue">OD After office hrs</th>
<th class="header-blue">RD Within office hrs &lt; 4hrs</th>
<th class="header-blue">RD Within office hrs &gt; 4hrs &lt; 8 hrs</th>
<th class="header-blue">RD After office hrs</th>
<th class="header-blue">PH Within office hrs &lt; 8 hrs</th>
<th class="header-blue">PH After office hrs</th>
</tr>
</thead>
<tbody>
<tr v-for="(record, index) in sortedOtRecords" :key="record.overtimeId">
<template v-if="index === 0 && !isApproverRole(['HoU', 'HoD', 'Manager'])">
<td :rowspan="sortedOtRecords.length" class="text-nowrap">{{ calculateBasicSalary() }}</td>
<td :rowspan="sortedOtRecords.length" class="text-nowrap">{{ calculateOrp() }}</td>
<td :rowspan="sortedOtRecords.length" class="text-nowrap">{{ calculateHrp() }}</td>
</template>
<td class="text-nowrap">{{ formatDate(record.otDate) }}</td>
<td>{{ formatTime(record.officeFrom) }}</td>
<td>{{ formatTime(record.officeTo) }}</td>
<td>{{ formatBreakToHourMinute(record.officeBreak, false) }}</td>
<td>{{ formatTime(record.afterFrom) }}</td>
<td>{{ formatTime(record.afterTo) }}</td>
<td>{{ formatBreakToHourMinute(record.afterBreak, false) }}</td>
<td>{{ formatTimeFromDecimal(calculateRawDuration(record.officeFrom, record.officeTo, record.officeBreak)) }}</td>
<td>{{ formatTimeFromDecimal(calculateRawDuration(record.afterFrom, record.afterTo, record.afterBreak)) }}</td>
<td>{{ classifyOt(record).ndAfter }}</td>
<td>{{ classifyOt(record).odUnder4 }}</td>
<td>{{ classifyOt(record).odBetween4And8 }}</td>
<td>{{ classifyOt(record).odAfter }}</td>
<td>{{ classifyOt(record).rdUnder4 }}</td>
<td>{{ classifyOt(record).rdBetween4And8 }}</td>
<td>{{ classifyOt(record).rdAfter }}</td>
<td>{{ classifyOt(record).phUnder8 }}</td>
<td>{{ classifyOt(record).phAfter }}</td>
<td>{{ calculateTotalOtHrs(record) }}</td>
<td>{{ calculateNdOdTotal(record) }}</td>
<td>{{ calculateRdTotal(record) }}</td>
<td>{{ calculatePhTotal(record) }}</td>
<td v-if="!isApproverRole(['HoU', 'HoD', 'Manager'])">{{ calculateOtAmount(record) }}</td>
<td v-if="showStationColumn">{{ record.stationName || 'N/A' }}</td>
<td class="wrap-text">
<div class="description-preview" v-on:click="toggleDescription(index)" :class="{ expanded: expandedDescriptions[index] }">
{{ record.otDescription }}
</div>
</td>
<td>
<button class="btn btn-light border rounded-circle me-1"
v-on:click="editRecord(record.overtimeId)"
:disabled="hasApproverActedLocal">
<i class="bi bi-pencil-fill text-warning fs-5"></i>
</button>
<button class="btn btn-light border rounded-circle"
v-on:click="deleteRecord(record.overtimeId)"
:disabled="hasApproverActedLocal">
<i class="bi bi-trash-fill text-danger fs-5"></i>
</button>
</td>
</tr>
<tr v-if="otRecords.length === 0">
<td :colspan="showStationColumn ? (isApproverRole(['HoU', 'HoD', 'Manager']) ? 27 : 31) : (isApproverRole(['HoU', 'HoD', 'Manager']) ? 26 : 30)">No overtime details found for this submission.</td>
</tr>
</tbody>
<tfoot>
<tr class="fw-bold bg-light">
<td v-if="!isApproverRole(['HoU', 'HoD', 'Manager'])" colspan="3">TOTAL</td>
<td v-else colspan="1">TOTAL</td>
<td v-if="!isApproverRole(['HoU', 'HoD', 'Manager'])" colspan="2"></td>
<td v-else colspan="2"></td>
<td><strong>{{ formatBreakToHourMinute(totals.officeBreak) }}</strong></td>
<td v-if="!isApproverRole(['HoU', 'HoD', 'Manager'])" colspan="2"></td>
<td v-else colspan="2"></td>
<td><strong>{{ formatBreakToHourMinute(totals.afterBreak) }}</strong></td>
<td v-if="!isApproverRole(['HoU', 'HoD', 'Manager'])" colspan="2"></td>
<td v-else colspan="2"></td>
<td>{{ totals.ndAfter }}</td>
<td>{{ totals.odUnder4 }}</td>
<td>{{ totals.odBetween4And8 }}</td>
<td>{{ totals.odAfter }}</td>
<td>{{ totals.rdUnder4 }}</td>
<td>{{ totals.rdBetween4And8 }}</td>
<td>{{ totals.rdAfter }}</td>
<td>{{ totals.phUnder8 }}</td>
<td>{{ totals.phAfter }}</td>
<td>{{ totals.totalOtHrs }}</td>
<td>{{ totals.totalNdOd }}</td>
<td>{{ totals.totalRd }}</td>
<td>{{ totals.totalPh }}</td>
<td v-if="!isApproverRole(['HoU', 'HoD', 'Manager'])">{{ totals.otAmt }}</td>
<td v-if="showStationColumn"></td>
<td colspan="3"></td>
</tr>
</tfoot>
</table>
</div>
<div class="mt-3 d-flex flex-wrap gap-2">
<button class="btn btn-primary btn-sm" v-on:click="printPdf"><i class="bi bi-printer"></i> Print</button>
<button class="btn btn-dark btn-sm" v-on:click="saveAsPdf"><i class="bi bi-file-pdf"></i> Save</button>
<button class="btn btn-success btn-sm" v-on:click="exportToExcel"><i class="bi bi-file-earmark-excel"></i> Excel</button>
</div>
<div class="modal fade" id="editOtModal" tabindex="-1" aria-labelledby="editOtModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editOtModalLabel">Edit Overtime Record</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="container">
<div class="row">
<div class="col-md-7">
<div class="mb-3">
<label class="form-label" for="modalDateInput">Date</label>
<input type="date" class="form-control" id="modalDateInput" v-model="currentEditRecord.otDate">
</div>
<h6 class="fw-bold">OFFICE HOURS</h6>
<small class="text-muted mb-3 d-block" v-if="flexiTimes.start && flexiTimes.end">Valid Office Hours ranges: {{ flexiTimes.start }} to {{ flexiTimes.end }}</small>
<div class="d-flex gap-3 mb-3 align-items-end flex-wrap">
<div style="flex: 1;">
<label for="modalOfficeFrom">From</label>
<input type="time" id="modalOfficeFrom" class="form-control"
v-model="currentEditRecord.officeFrom"
v-on:change="roundMinutes('officeFrom'); validateTimeFields()"
:disabled="isModalOfficeHoursDisabled">
</div>
<div style="flex: 1;">
<label for="modalOfficeTo">To</label>
<input type="time" id="modalOfficeTo" class="form-control"
v-model="currentEditRecord.officeTo"
v-on:change="roundMinutes('officeTo'); validateTimeFields()"
:disabled="isModalOfficeHoursDisabled">
</div>
<div style="flex: 1;">
<label for="modalOfficeBreak">Break Hours (Min)</label>
<div class="d-flex">
<select id="modalOfficeBreak" class="form-control" v-model.number="currentEditRecord.officeBreak"
:disabled="isModalOfficeHoursDisabled">
<option v-for="opt in breakOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<button class="btn btn-outline-danger ms-2" v-on:click="clearOfficeHours"
title="Clear Office Hours" :disabled="isModalOfficeHoursDisabled">
<i class="bi bi-x-circle"></i>
</button>
</div>
</div>
</div>
<h6 class="fw-bold text-danger">AFTER OFFICE HOURS</h6>
<small class="text-muted mb-3 d-block" v-if="flexiTimes.start && flexiTimes.end">Valid After Office Hours ranges: 00:00 - {{ flexiTimes.start }} or {{ flexiTimes.end }} - 00:00</small>
<div class="d-flex gap-3 mb-3 align-items-end flex-wrap">
<div style="flex: 1;">
<label for="modalAfterFrom">From</label>
<input type="time" id="modalAfterFrom" class="form-control"
v-model="currentEditRecord.afterFrom"
v-on:change="roundMinutes('afterFrom'); validateTimeFields()">
</div>
<div style="flex: 1;">
<label for="modalAfterTo">To</label>
<input type="time" id="modalAfterTo" class="form-control"
v-model="currentEditRecord.afterTo"
v-on:change="roundMinutes('afterTo'); validateTimeFields()">
</div>
<div style="flex: 1;">
<label for="modalAfterBreak">Break Hours (Min)</label>
<div class="d-flex">
<select id="modalAfterBreak" class="form-control" v-model.number="currentEditRecord.afterBreak">
<option v-for="opt in breakOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<button class="btn btn-outline-danger ms-2" v-on:click="clearAfterHours" title="Clear After Office Hours">
<i class="bi bi-x-circle"></i>
</button>
</div>
</div>
</div>
<div class="mb-3" v-show="showAirStationDropdown">
<label for="modalAirstationDropdown">Air Station</label>
<select id="modalAirstationDropdown" class="form-control select2-enable" style="width: 100%;">
<option value="" disabled selected>Select Air Station</option>
<option v-for="station in airStations" :key="station.stationId" :value="station.stationId">
{{ station.stationName || 'Unnamed Station' }}
</option>
</select>
<small class="text-danger">*Only for PSTW AIR or Admins</small>
</div>
<div class="mb-3" v-show="showMarineStationDropdown">
<label for="modalMarinestationDropdown">Marine Station</label>
<select id="modalMarinestationDropdown" class="form-control select2-enable" style="width: 100%;">
<option value="" disabled selected>Select Marine Station</option>
<option v-for="station in marineStations" :key="station.stationId" :value="station.stationId">
{{ station.stationName || 'Unnamed Station' }}
</option>
</select>
<small class="text-danger">*Only for PSTW MARINE or Admins</small>
</div>
<div class="mb-3">
<label for="modalOtDescription">Work Brief Description <span class="text-danger">*</span></label>
<textarea id="modalOtDescription" class="form-control" v-model="currentEditRecord.otDescription" placeholder="Describe the work done..." required></textarea>
<small class="text-danger" v-if="descriptionError">{{ descriptionError }}</small>
<small class="text-muted">{{ currentEditRecord.otDescription ? currentEditRecord.otDescription.length : 0 }} / 150 characters</small>
</div>
</div>
<div class="col-md-5 mt-5">
<div class="mb-3 d-flex flex-column align-items-center">
<label for="modalUserFlexiHourDisplay">Your Flexi Hour</label>
<input type="text" id="modalUserFlexiHourDisplay" class="form-control text-center" :value="userInfo.flexiHour || 'N/A'" readonly style="width: 200px;">
</div>
<div class="mb-3 d-flex flex-column align-items-center">
<label for="modalDetectedDayType">Day</label>
<input type="text" class="form-control text-center"
:value="getDayType(currentEditRecord.otDate)"
readonly style="width: 200px;">
</div>
<div class="mb-3 d-flex flex-column align-items-center">
<label for="modalTotalOTHours">Total OT Hours</label>
<input type="text" id="modalTotalOTHours" class="form-control text-center" :value="calculateModalTotalOtHrs()" style="width: 200px;" readonly>
</div>
<div class="mb-3 d-flex flex-column align-items-center">
<label for="modalTotalBreakHours">Total Break Hours</label>
<input type="text" id="modalTotalBreakHours" class="form-control text-center" :value="calculateModalTotalBreakHrs()" style="width: 200px;" readonly>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" v-on:click="submitEdit">Save changes</button>
</div>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
const reviewApp = Vue.createApp({
data() {
return {
otRecords: [],
userInfo: {
fullName: '',
departmentName: '',
departmentId: null,
filePath: '',
userName: '',
flexiHour: '',
stateId: null,
weekendId: null,
basicSalary: null
},
isHoU: false,
expandedDescriptions: [],
currentEditRecord: {
overtimeId: null,
otDate: '',
officeFrom: '',
officeTo: '',
officeBreak: 0,
afterFrom: '',
afterTo: '',
afterBreak: 0,
stationId: null,
otDescription: '',
statusId: null,
otDays: '' // This will store the day type for the modal's selected date
},
airStations: [],
marineStations: [],
approverRole: '',
houStatus: '',
hodStatus: '',
managerStatus: '',
hrStatus: '',
userState: null,
publicHolidays: [],
breakOptions: Array.from({ length: 15 }, (_, i) => {
const totalMinutes = i * 30;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
let label = '';
if (hours > 0) label += `${hours} hour${hours > 1 ? 's' : ''}`;
if (minutes > 0) label += `${label ? ' ' : ''}${minutes} min`;
if (!label) label = '0 min';
return { label, value: totalMinutes };
}),
};
},
computed: {
showStationColumn() {
const dep = this.userInfo.departmentId;
const fullName = this.userInfo.fullName?.toLowerCase();
return dep === 2 || dep === 3 || fullName === "sysadmin" || fullName === "maadmin";
},
showAirStationDropdown() {
const dep = this.userInfo.departmentId;
const fullName = this.userInfo.fullName?.toLowerCase();
return dep === 2 || fullName === "sysadmin" || fullName === "maadmin";
},
showMarineStationDropdown() {
const dep = this.userInfo.departmentId;
const fullName = this.userInfo.fullName?.toLowerCase();
return dep === 3 || fullName === "sysadmin" || fullName === "maadmin";
},
sortedOtRecords() {
return [...this.otRecords].sort((a, b) => {
const dateA = new Date(a.otDate);
const dateB = new Date(b.otDate);
return dateA - dateB;
});
},
totals() {
const totals = {
officeBreak: 0,
afterBreak: 0,
ndAfter: 0,
odUnder4: 0, odBetween4And8: 0, odAfter: 0,
rdUnder4: 0, rdBetween4And8: 0, rdAfter: 0,
phUnder8: 0, phAfter: 0,
totalOtHrs: 0,
totalNdOd: 0,
totalRd: 0,
totalPh: 0,
otAmt: 0
};
this.otRecords.forEach(record => {
const classified = this.classifyOt(record);
totals.officeBreak += record.officeBreak || 0;
totals.afterBreak += record.afterBreak || 0;
totals.ndAfter += parseFloat(classified.ndAfter) || 0;
totals.odUnder4 += parseFloat(classified.odUnder4) || 0;
totals.odBetween4And8 += parseFloat(classified.odBetween4And8) || 0;
totals.odAfter += parseFloat(classified.odAfter) || 0;
totals.rdUnder4 += parseFloat(classified.rdUnder4) || 0;
totals.rdBetween4And8 += parseFloat(classified.rdBetween4And8) || 0;
totals.rdAfter += parseFloat(classified.rdAfter) || 0;
totals.phUnder8 += parseFloat(classified.phUnder8) || 0;
totals.phAfter += parseFloat(classified.phAfter) || 0;
totals.totalOtHrs += parseFloat(this.calculateTotalOtHrs(record)) || 0;
totals.totalNdOd += parseFloat(this.calculateNdOdTotal(record)) || 0;
totals.totalRd += parseFloat(this.calculateRdTotal(record)) || 0;
totals.totalPh += parseFloat(this.calculatePhTotal(record)) || 0;
totals.otAmt += parseFloat(this.calculateOtAmount(record)) || 0;
});
for (let key in totals) {
if (key.endsWith('Hrs') || key.endsWith('Amt')) {
if (key === 'otAmt') {
totals[key] = Math.round(totals[key]).toFixed(2);
} else {
totals[key] = totals[key].toFixed(2);
}
} else if (key.endsWith('Break')) {
} else {
totals[key] = totals[key].toFixed(2);
}
}
return totals;
},
hasApproverActedLocal() {
switch (this.approverRole) {
case "HoU":
return this.houStatus !== null && this.houStatus !== '' && this.houStatus !== 'Pending';
case "HoD":
return this.hodStatus !== null && this.hodStatus !== '' && this.hodStatus !== 'Pending';
case "Manager":
return this.managerStatus !== null && this.managerStatus !== '' && this.managerStatus !== 'Pending';
case "HR":
return this.hrStatus !== null && this.hrStatus !== '' && this.hrStatus !== 'Pending';
default:
return true;
}
},
isModalOfficeHoursDisabled() {
return this.getDayType(this.currentEditRecord.otDate) === "Normal Day";
},
flexiTimes() {
if (!this.userInfo || !this.userInfo.flexiHour) {
return { start: 'N/A', end: 'N/A' };
}
const times = this.userInfo.flexiHour.split(' - ');
return {
start: times[0],
end: times[1]
};
}
},
watch: {
'currentEditRecord.otDate': function(newDate) {
this.currentEditRecord.otDays = this.getDayType(newDate);
if (this.isModalOfficeHoursDisabled) {
this.clearOfficeHours();
}
this.validateTimeFields();
this.calculateModalTotalOtHrs();
this.calculateModalTotalBreakHrs();
},
'currentEditRecord.stationId': function(newVal) {
if (this.showAirStationDropdown) {
$('#modalAirstationDropdown').val(newVal).trigger('change.select2');
} else if (this.showMarineStationDropdown) {
$('#modalMarinestationDropdown').val(newVal).trigger('change.select2');
}
},
airStations: function() {
if (this.showAirStationDropdown) {
this.initSelect2('#modalAirstationDropdown');
}
},
marineStations: function() {
if (this.showMarineStationDropdown) {
this.initSelect2('#modalMarinestationDropdown');
}
}
},
methods: {
isApproverRole(rolesToHide) {
return rolesToHide.includes(this.approverRole);
},
toggleDescription(index) {
this.expandedDescriptions[index] = !this.expandedDescriptions[index];
},
async fetchOtRecords() {
const params = new URLSearchParams(window.location.search);
const statusId = params.get("statusId");
try {
const res = await fetch(`/OvertimeAPI/GetOtRecordsByStatusId/${statusId}`);
const data = await res.json();
this.otRecords = data.records;
this.userInfo = {
...data.userInfo,
basicSalary: data.userInfo.rate
};
this.isHoU = data.isHoU;
this.currentEditRecord.statusId = parseInt(statusId);
this.approverRole = data.approverRole;
this.houStatus = data.houStatus;
this.hodStatus = data.hodStatus;
this.managerStatus = data.managerStatus;
this.hrStatus = data.hrStatus;
this.userState = {
stateId: data.userInfo.stateId,
weekendId: data.userInfo.weekendId
};
await this.fetchPublicHolidays(this.userState.stateId);
} catch (err) {
console.error("Error fetching OT records:", err);
}
},
async fetchStations() {
try {
const airRes = await fetch('/OvertimeAPI/GetStationsByDepartmentAir');
this.airStations = await airRes.json();
const marineRes = await fetch('/OvertimeAPI/GetStationsByDepartmentMarine');
this.marineStations = await marineRes.json();
} catch (err) {
console.error("Error fetching stations:", err);
}
},
initSelect2(selector) {
if ($(selector).data('select2')) {
$(selector).select2('destroy');
}
$(selector).select2({
dropdownParent: $('#editOtModal')
}).on('change', (e) => {
this.currentEditRecord.stationId = $(e.currentTarget).val();
});
if (this.currentEditRecord.stationId) {
$(selector).val(this.currentEditRecord.stationId).trigger('change.select2');
}
},
calculateHrp() {
const orp = parseFloat(this.calculateOrp());
if (isNaN(orp) || orp <= 0) return 'N/A';
return (orp / 8).toFixed(2);
},
calculateOrp() {
const basicSalary = parseFloat(this.userInfo.basicSalary);
if (isNaN(basicSalary) || basicSalary <= 0) return 'N/A';
return (basicSalary / 26).toFixed(2);
},
calculateBasicSalary() {
const basicSalary = parseFloat(this.userInfo.basicSalary);
if (isNaN(basicSalary) || basicSalary <= 0) return 'N/A';
return basicSalary.toFixed(2);
},
formatDate(dateStr) {
const d = new Date(dateStr);
if (isNaN(d.getTime())) {
return '';
}
const day = d.getDate().toString().padStart(2, '0');
const month = (d.getMonth() + 1).toString().padStart(2, '0');
const year = d.getFullYear();
return `${day}/${month}/${year}`;
},
formatTime(timeSpanStr) {
if (!timeSpanStr) return '';
const parts = timeSpanStr.split(':');
if (parts.length >= 2) {
return `${parts[0].padStart(2, '0')}:${parts[1].padStart(2, '0')}`;
}
return '';
},
formatBreakToHourMinute(breakValue, showZero = true) {
const minutes = parseFloat(breakValue || 0);
if (isNaN(minutes) || minutes <= 0) return showZero ? '0:00' : '';
const hrs = Math.floor(minutes / 60);
const mins = Math.round(minutes % 60);
return `${hrs.toString().padStart(1, '0')}:${mins.toString().padStart(2, '0')}`;
},
formatTimeFromDecimal(decimalHours) {
if (decimalHours === null || isNaN(decimalHours) || decimalHours <= 0) return '';
const totalMinutes = Math.round(decimalHours * 60);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${hours}:${minutes.toString().padStart(2, '0')}`;
},
parseTimeSpanToDecimalHours(timeStr) {
if (!timeStr || typeof timeStr !== 'string') return 0;
const parts = timeStr.split(':');
if (parts.length !== 2) return 0;
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
if (isNaN(hours) || isNaN(minutes)) return 0;
return hours + (minutes / 60);
},
calculateRawDuration(fromTime, toTime, breakMins) {
if (!fromTime || !toTime) return 0;
const parseTime = (timeStr) => {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
};
const from = parseTime(fromTime);
const to = parseTime(toTime);
const breakMinutes = parseFloat(breakMins || 0);
let totalMinutes = to - from - breakMinutes;
if (totalMinutes < 0) totalMinutes += 24 * 60;
return Math.max(0, totalMinutes / 60);
},
calculateOfficeOtHours(record) {
if (!record.officeFrom || !record.officeTo) return '';
if (record.dayType === 'Normal Day') {
return '';
}
const totalHours = this.calculateRawDuration(record.officeFrom, record.officeTo, record.officeBreak);
return this.formatTimeFromDecimal(totalHours);
},
calculateAfterOfficeOtHours(record) {
if (!record.afterFrom || !record.afterTo) return '';
const totalHours = this.calculateRawDuration(record.afterFrom, record.afterTo, record.afterBreak);
return this.formatTimeFromDecimal(totalHours);
},
classifyOt(record) {
const officeHrs = this.calculateRawDuration(record.officeFrom, record.officeTo, record.officeBreak);
const afterHrs = this.calculateRawDuration(record.afterFrom, record.afterTo, record.afterBreak);
const toFixedOrEmpty = (num) => num > 0 ? num.toFixed(2) : '';
const result = {
ndAfter: '',
odUnder4: '', odBetween4And8: '', odAfter: '',
rdUnder4: '', rdBetween4And8: '', rdAfter: '',
phUnder8: '', phAfter: ''
};
const dayType = record.dayType || this.getDayType(record.otDate);
switch (dayType) {
case 'Normal Day':
result.ndAfter = toFixedOrEmpty(afterHrs);
break;
case 'Off Day':
case 'Weekend': // Assume Weekend is handled like Off Day if not explicitly categorized
if (officeHrs > 0) {
if (officeHrs <= 4) {
result.odUnder4 = toFixedOrEmpty(officeHrs);
} else if (officeHrs <= 8) {
result.odBetween4And8 = toFixedOrEmpty(officeHrs);
} else {
result.odAfter = toFixedOrEmpty(officeHrs);
}
}
result.odAfter = toFixedOrEmpty((parseFloat(result.odAfter) || 0) + afterHrs);
break;
case 'Rest Day':
if (officeHrs > 0) {
if (officeHrs <= 4) {
result.rdUnder4 = toFixedOrEmpty(officeHrs);
} else if (officeHrs <= 8) {
result.rdBetween4And8 = toFixedOrEmpty(officeHrs);
} else {
result.rdAfter = toFixedOrEmpty(officeHrs);
}
}
result.rdAfter = toFixedOrEmpty((parseFloat(result.rdAfter) || 0) + afterHrs);
break;
case 'Public Holiday':
if (officeHrs > 0) {
if (officeHrs <= 8) {
result.phUnder8 = toFixedOrEmpty(officeHrs);
} else {
result.phAfter = toFixedOrEmpty(officeHrs);
}
}
result.phAfter = toFixedOrEmpty((parseFloat(result.phAfter) || 0) + afterHrs);
break;
}
return result;
},
calculateTotalOtHrs(record) {
const classified = this.classifyOt(record);
let total = 0;
for (const key in classified) {
const value = parseFloat(classified[key]);
if (!isNaN(value)) total += value;
}
return total.toFixed(2);
},
calculateNdOdTotal(record) {
const classified = this.classifyOt(record);
const nd = parseFloat(classified.ndAfter) || 0;
const od1 = parseFloat(classified.odUnder4) || 0;
const od2 = parseFloat(classified.odBetween4And8) || 0;
const od3 = parseFloat(classified.odAfter) || 0;
const total = nd + od1 + od2 + od3;
return total > 0 ? total.toFixed(2) : '';
},
calculateRdTotal(record) {
const classified = this.classifyOt(record);
const total =
(parseFloat(classified.rdUnder4) || 0) +
(parseFloat(classified.rdBetween4And8) || 0) +
(parseFloat(classified.rdAfter) || 0);
return total > 0 ? total.toFixed(2) : '';
},
calculatePhTotal(record) {
const classified = this.classifyOt(record);
const total =
(parseFloat(classified.phUnder8) || 0) +
(parseFloat(classified.phAfter) || 0);
return total > 0 ? total.toFixed(2) : '';
},
calculateOtAmount(record) {
const basicSalary = parseFloat(this.userInfo.basicSalary) || 0;
if (basicSalary === 0) return '0.00';
const orp = basicSalary / 26;
const hrp = orp / 8;
const otType = record.dayType || this.getDayType(record.otDate);
const officeHours = this.calculateRawDuration(record.officeFrom, record.officeTo, record.officeBreak);
const afterOfficeHours = this.calculateRawDuration(record.afterFrom, record.afterTo, record.afterBreak);
let amountOffice = 0;
let amountAfter = 0;
if (officeHours > 0) {
if (otType === 'Off Day' || otType === 'Rest Day') {
if (officeHours <= 4) {
amountOffice = 0.5 * orp;
} else if (officeHours <= 8) {
amountOffice = 1 * orp;
} else {
amountOffice = 1 * orp; // For >8 hours, still based on 1 ORP for office hours
}
} else if (otType === 'Public Holiday') {
amountOffice = 2 * orp;
}
}
if (afterOfficeHours > 0) {
switch (otType) {
case 'Normal Day':
case 'Off Day':
amountAfter = 1.5 * hrp * afterOfficeHours;
break;
case 'Rest Day':
amountAfter = 2 * hrp * afterOfficeHours;
break;
case 'Public Holiday':
amountAfter = 3 * hrp * afterOfficeHours;
break;
}
}
const totalAmount = amountOffice + amountAfter;
return totalAmount.toFixed(2);
},
roundMinutes(fieldName) {
const timeStr = this.currentEditRecord[fieldName];
if (!timeStr) return;
const [hours, minutes] = timeStr.split(':').map(Number);
const totalMinutes = hours * 60 + minutes;
const remainder = totalMinutes % 30;
const roundedMinutes = remainder < 15 ? totalMinutes - remainder : totalMinutes + (30 - remainder);
const adjustedHour = Math.floor(roundedMinutes / 60) % 24;
const adjustedMinute = roundedMinutes % 60;
this.currentEditRecord[fieldName] = `${adjustedHour.toString().padStart(2, '0')}:${adjustedMinute.toString().padStart(2, '0')}`;
this.validateTimeFields();
},
editRecord(id) {
if (this.hasApproverActedLocal) {
alert("You cannot edit this record as you have already approved/rejected this OT submission.");
return;
}
const recordToEdit = this.otRecords.find(record => record.overtimeId === id);
if (recordToEdit) {
const date = new Date(recordToEdit.otDate);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const formattedDate = `${year}-${month}-${day}`;
this.currentEditRecord = {
overtimeId: recordToEdit.overtimeId,
otDate: formattedDate,
officeFrom: this.formatTime(recordToEdit.officeFrom),
officeTo: this.formatTime(recordToEdit.officeTo),
officeBreak: recordToEdit.officeBreak || 0,
afterFrom: this.formatTime(recordToEdit.afterFrom),
afterTo: this.formatTime(recordToEdit.afterTo),
afterBreak: recordToEdit.afterBreak || 0,
stationId: recordToEdit.stationId,
otDescription: recordToEdit.otDescription,
statusId: this.currentEditRecord.statusId, // Retain the overall statusId
otDays: recordToEdit.dayType || this.getDayType(formattedDate) // Ensure otDays is set correctly
};
if (this.isModalOfficeHoursDisabled) {
this.clearOfficeHours();
}
this.$nextTick(() => {
if (this.showAirStationDropdown) {
this.initSelect2('#modalAirstationDropdown');
$('#modalAirstationDropdown').val(this.currentEditRecord.stationId).trigger('change.select2');
}
if (this.showMarineStationDropdown) {
this.initSelect2('#modalMarinestationDropdown');
$('#modalMarinestationDropdown').val(this.currentEditRecord.stationId).trigger('change.select2');
}
const editModal = new bootstrap.Modal(document.getElementById('editOtModal'));
editModal.show();
});
}
},
async submitEdit() {
try {
// MODIFIED: Check for validation failure and exit early
if (!this.validateModalForm()) {
return;
}
const response = await fetch('/OvertimeAPI/UpdateOtRecordByApprover', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
...this.currentEditRecord,
OfficeFrom: this.isModalOfficeHoursDisabled ? null : this.currentEditRecord.officeFrom,
OfficeTo: this.isModalOfficeHoursDisabled ? null : this.currentEditRecord.officeTo,
OfficeBreak: this.isModalOfficeHoursDisabled ? 0 : this.currentEditRecord.officeBreak
})
});
const data = await response.json();
if (response.ok) {
alert(data.message);
const editModal = bootstrap.Modal.getInstance(document.getElementById('editOtModal'));
if (editModal) editModal.hide();
await this.fetchOtRecords();
} else {
alert(`Error: ${data.message || 'Failed to update record.'}`);
}
} catch (error) {
console.error('Error submitting edit:', error);
alert('An error occurred while updating the record.');
}
},
// MODIFIED: validateModalForm to return early on first error
validateModalForm() {
// Reset validation errors
this.descriptionError = "";
// 1. Check for Work Brief Description
if (!this.currentEditRecord.otDescription || this.currentEditRecord.otDescription.trim() === "") {
this.descriptionError = "Work brief description is required.";
alert(this.descriptionError);
return false;
}
let errorMessages = [];
if ((this.showAirStationDropdown || this.showMarineStationDropdown) && !this.currentEditRecord.stationId) {
errorMessages.push("Station is required.");
}
const officeFromFilled = !!this.currentEditRecord.officeFrom;
const officeToFilled = !!this.currentEditRecord.officeTo;
const afterFromFilled = !!this.currentEditRecord.afterFrom;
const afterToFilled = !!this.currentEditRecord.afterTo;
if (!this.isModalOfficeHoursDisabled) {
if (officeFromFilled !== officeToFilled) {
errorMessages.push("Both 'From' and 'To' times must be provided for Office Hours, or leave both empty.");
} else if (officeFromFilled && officeToFilled) {
if (!this.validateTimeRangeForSubmission(this.currentEditRecord.officeFrom, this.currentEditRecord.officeTo, 'Office Hour')) {
// validateTimeRangeForSubmission already shows an alert, so we just return false
return false;
}
}
}
if (afterFromFilled !== afterToFilled) {
errorMessages.push("Both 'From' and 'To' times must be provided for After Office Hours, or leave both empty.");
} else if (afterFromFilled && afterToFilled) {
if (!this.validateTimeRangeForSubmission(this.currentEditRecord.afterFrom, this.currentEditRecord.afterTo, 'After Office Hour')) {
// validateTimeRangeForSubmission already shows an alert, so we just return false
return false;
}
}
const hasAnyCompleteTimeEntry = (
(!this.isModalOfficeHoursDisabled && officeFromFilled && officeToFilled) ||
(afterFromFilled && afterToFilled)
);
if (!hasAnyCompleteTimeEntry && errorMessages.length === 0) {
errorMessages.push("Please enter either Office Hours or After Office Hours.");
}
// Call the overlap check here. It will show its own alert.
if (!this.checkOverlaps(this.currentEditRecord)) {
return false;
}
// If there are any other validation errors, show them and return false.
if (errorMessages.length > 0) {
alert("Please correct the following issues:\n\n" + errorMessages.join("\n"));
return false;
}
return true;
},
// NEW METHOD: checkOverlaps
checkOverlaps(recordToValidate) {
const existingRecordsOnSameDate = this.otRecords.filter(
r => r.overtimeId !== recordToValidate.overtimeId &&
r.otDate.slice(0, 10) === recordToValidate.otDate
);
const overlaps = existingRecordsOnSameDate.some(existingRecord => {
const newRanges = [];
if (recordToValidate.officeFrom && recordToValidate.officeTo) newRanges.push({ start: this.parseTime(recordToValidate.officeFrom), end: this.parseTime(recordToValidate.officeTo) });
if (recordToValidate.afterFrom && recordToValidate.afterTo) newRanges.push({ start: this.parseTime(recordToValidate.afterFrom), end: this.parseTime(recordToValidate.afterTo) });
const existingRanges = [];
if (existingRecord.officeFrom && existingRecord.officeTo) existingRanges.push({ start: this.parseTime(this.formatTime(existingRecord.officeFrom)), end: this.parseTime(this.formatTime(existingRecord.officeTo)) });
if (existingRecord.afterFrom && existingRecord.afterTo) existingRanges.push({ start: this.parseTime(this.formatTime(existingRecord.afterFrom)), end: this.parseTime(this.formatTime(existingRecord.afterTo)) });
for (const newRange of newRanges) {
for (const existingRange of existingRanges) {
if (this.areTimeRangesOverlapping(newRange, existingRange)) {
return true;
}
}
}
return false;
});
if (overlaps) {
alert("An overtime entry for this date overlaps with an existing entry. Please adjust the time ranges.");
return false;
}
return true;
},
// NEW HELPER METHOD: areTimeRangesOverlapping
areTimeRangesOverlapping(range1, range2) {
const start1 = range1.start.hours * 60 + range1.start.minutes;
let end1 = range1.end.hours * 60 + range1.end.minutes;
const start2 = range2.start.hours * 60 + range2.start.minutes;
let end2 = range2.end.hours * 60 + range2.end.minutes;
if (end1 <= start1) end1 += 24 * 60;
if (end2 <= start2) end2 += 24 * 60;
return !(end1 <= start2 || end2 <= start1);
},
async fetchPublicHolidays(stateId) {
if (!stateId) {
this.publicHolidays = [];
return;
}
try {
const res = await fetch(`/OvertimeAPI/GetPublicHolidaysByState/${stateId}`);
const data = await res.json();
this.publicHolidays = data;
} catch (err) {
console.error("Error fetching public holidays:", err);
this.publicHolidays = [];
}
},
getDayType(dateStr) {
if (!dateStr || !this.userState) return '';
const selectedDate = new Date(dateStr + "T00:00:00");
if (isNaN(selectedDate.getTime())) return '';
const dayOfWeek = selectedDate.getDay();
const year = selectedDate.getFullYear();
const month = selectedDate.getMonth() + 1;
const day = selectedDate.getDate();
const formattedDateKey = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
if (this.publicHolidays.some(holiday => holiday.date === formattedDateKey)) {
return 'Public Holiday';
}
if (this.userState.weekendId === 1) {
if (dayOfWeek === 5) return 'Off Day';
if (dayOfWeek === 6) return 'Rest Day';
else return 'Normal Day';
} else if (this.userState.weekendId === 2) {
if (dayOfWeek === 6) return 'Off Day';
if (dayOfWeek === 0) return 'Rest Day';
else return 'Normal Day';
} else {
if (dayOfWeek === 0) return 'Rest Day';
else return 'Normal Day';
}
},
calculateModalTotalOtHrs() {
const officeHrsDecimal = this.calculateRawDuration(
this.currentEditRecord.officeFrom,
this.currentEditRecord.officeTo,
this.currentEditRecord.officeBreak
);
const afterHrsDecimal = this.calculateRawDuration(
this.currentEditRecord.afterFrom,
this.currentEditRecord.afterTo,
this.currentEditRecord.afterBreak
);
const totalMinutes = Math.round((officeHrsDecimal + afterHrsDecimal) * 60);
const totalHours = Math.floor(totalMinutes / 60);
const remainingMinutes = totalMinutes % 60;
return `${totalHours} hr ${remainingMinutes} min`;
},
calculateModalTotalBreakHrs() {
const totalBreakMinutes = (this.currentEditRecord.officeBreak || 0) + (this.currentEditRecord.afterBreak || 0);
const hours = Math.floor(totalBreakMinutes / 60);
const minutes = totalBreakMinutes % 60;
return `${hours} hr ${minutes} min`;
},
parseTime(timeString) {
if (!timeString) return { hours: -1, minutes: -1 };
const [hours, minutes] = timeString.split(':').map(Number);
return { hours, minutes };
},
clearOfficeHours() {
this.currentEditRecord.officeFrom = "";
this.currentEditRecord.officeTo = "";
this.currentEditRecord.officeBreak = 0;
this.calculateModalTotalOtHrs();
this.calculateModalTotalBreakHrs();
},
clearAfterHours() {
this.currentEditRecord.afterFrom = "";
this.currentEditRecord.afterTo = "";
this.currentEditRecord.afterBreak = 0;
this.calculateModalTotalOtHrs();
this.calculateModalTotalBreakHrs();
},
deleteRecord(id) {
if (this.hasApproverActedLocal) {
alert("You cannot delete this record as you have already approved/rejected this OT submission.");
return;
}
if (!confirm("Are you sure you want to delete this record?")) return;
fetch(`/OvertimeAPI/DeleteOvertimeInOtReview/${id}`, {
method: 'DELETE'
})
.then(res => {
if (!res.ok) {
if (res.status === 403) {
return res.json().then(errorData => { throw new Error(errorData.message || "Forbidden: Not authorized to delete."); });
}
throw new Error("Failed to delete record.");
}
return res.json();
})
.then(data => {
alert(data.message);
this.fetchOtRecords();
})
.catch(err => {
console.error(err);
alert("Error deleting record: " + err.message);
});
},
saveAsPdf() {
const params = new URLSearchParams(window.location.search);
const statusId = params.get("statusId");
fetch(`/OvertimeAPI/GetOvertimePdfByStatusId/${statusId}`)
.then(response => {
if (!response.ok) throw new Error("Failed to generate PDF");
const disposition = response.headers.get("Content-Disposition");
let filename = `OvertimeReview_${statusId}.pdf`;
if (disposition && disposition.includes("filename=")) {
const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (match && match[1]) {
filename = match[1].replace(/['"]/g, '');
}
}
return response.blob().then(blob => ({ blob, filename }));
})
.then(({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
})
.catch(error => {
console.error("Error generating PDF:", error);
alert("Failed to generate PDF. Please try again later.");
});
},
printPdf() {
const params = new URLSearchParams(window.location.search);
const statusId = params.get("statusId");
fetch(`/OvertimeAPI/GetOvertimePdfByStatusId/${statusId}`)
.then(response => {
if (!response.ok) throw new Error("Failed to fetch PDF for printing.");
return response.blob();
})
.then(blob => {
const url = window.URL.createObjectURL(blob);
const printWindow = window.open(url, '_blank');
if (printWindow) {
printWindow.onload = () => {
printWindow.focus();
printWindow.print();
};
} else {
alert("Failed to open print window. Please check your browser's pop-up blocker settings.");
}
window.URL.revokeObjectURL(url);
})
.catch(error => {
console.error("Error printing PDF:", error);
alert("Failed to prepare PDF for printing. Please try again later.");
});
},
exportToExcel() {
const params = new URLSearchParams(window.location.search);
const statusId = params.get("statusId");
fetch(`/OvertimeAPI/GetOvertimeExcelByStatusId/${statusId}`)
.then(response => {
if (!response.ok) throw new Error("Failed to generate Excel");
const disposition = response.headers.get("Content-Disposition");
let filename = `OvertimeReview_${statusId}.xlsx`;
if (disposition && disposition.includes("filename=")) {
const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
if (match && match[1]) {
filename = match[1].replace(/['"]/g, '');
}
}
return response.blob().then(blob => ({ blob, filename }));
})
.then(({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
})
.catch(error => {
console.error("Error generating Excel:", error);
alert("Failed to generate Excel. Please try again later.");
});
},
validateTimeRangeForSubmission(fromTime, toTime, label) {
if (!fromTime || !toTime) return true;
const start = this.parseTime(fromTime);
const end = this.parseTime(toTime);
const minAllowedFromMinutesForMidnightTo = 16 * 60 + 30;
const maxAllowedFromMidnightTo = 23 * 60 + 30;
const startMinutes = start.hours * 60 + start.minutes;
if (end.hours === 0 && end.minutes === 0) {
if (fromTime === "00:00") {
alert(`Invalid ${label} Time: 'From' and 'To' cannot both be 00:00 (midnight).`);
return false;
}
if (startMinutes < minAllowedFromMinutesForMidnightTo || startMinutes > maxAllowedFromMidnightTo) {
alert(`Invalid ${label} Time: If 'To' is 12:00 am (00:00), 'From' must start between end of your flexi hour to 11:30 pm on the same day.`);
return false;
}
} else if (end.hours * 60 + end.minutes <= start.hours * 60 + start.minutes) {
alert(`Invalid ${label} Time: 'To' time must be later than 'From' time for durations within the same day.`);
return false;
}
return true;
},
validateTimeFields() {
this.calculateModalTotalOtHrs();
this.calculateModalTotalBreakHrs();
},
},
async created() {
await this.fetchOtRecords();
await this.fetchStations();
},
mounted() {
if (this.showAirStationDropdown) {
this.initSelect2('#modalAirstationDropdown');
}
if (this.showMarineStationDropdown) {
this.initSelect2('#modalMarinestationDropdown');
}
var editModalElement = document.getElementById('editOtModal');
if (editModalElement) {
editModalElement.addEventListener('shown.bs.modal', () => {
if (this.showAirStationDropdown) {
this.initSelect2('#modalAirstationDropdown');
$('#modalAirstationDropdown').val(this.currentEditRecord.stationId).trigger('change.select2');
}
if (this.showMarineStationDropdown) {
this.initSelect2('#modalMarinestationDropdown');
$('#modalMarinestationDropdown').val(this.currentEditRecord.stationId).trigger('change.select2');
}
this.currentEditRecord.otDays = this.getDayType(this.currentEditRecord.otDate);
if (this.isModalOfficeHoursDisabled) {
this.clearOfficeHours();
}
this.validateTimeFields();
});
}
}
});
reviewApp.mount("#reviewApp");
</script>
}