PSTW_CentralizeSystem/Areas/OTcalculate/Views/ApprovalDashboard/OtReview.cshtml
2025-07-24 09:22:59 +08:00

1263 lines
62 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="3"></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="dateInput">Date</label>
<input type="date" class="form-control" id="dateInput" v-model="currentEditRecord.otDate">
</div>
<h6 class="fw-bold">OFFICE HOURS</h6>
<div class="d-flex gap-3 mb-3 align-items-end flex-wrap">
<div style="flex: 1;">
<label for="officeFrom">From</label>
<input type="time" id="officeFrom" class="form-control"
v-model="currentEditRecord.officeFrom"
v-on:change="roundMinutes('officeFrom'); validateTimeFields()">
</div>
<div style="flex: 1;">
<label for="officeTo">To</label>
<input type="time" id="officeTo" class="form-control"
v-model="currentEditRecord.officeTo"
v-on:change="roundMinutes('officeTo'); validateTimeFields()">
</div>
<div style="flex: 1;">
<label for="officeBreak">Break Hours (Minutes)</label>
<div class="d-flex">
<select id="officeBreak" class="form-control" v-model.number="currentEditRecord.officeBreak">
<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">
<i class="bi bi-x-circle"></i>
</button>
</div>
</div>
</div>
<h6 class="fw-bold text-danger">AFTER OFFICE HOURS</h6>
<div class="d-flex gap-3 mb-3 align-items-end flex-wrap">
<div style="flex: 1;">
<label for="afterFrom">From</label>
<input type="time" id="afterFrom" class="form-control"
v-model="currentEditRecord.afterFrom"
v-on:change="roundMinutes('afterFrom'); validateTimeFields()">
</div>
<div style="flex: 1;">
<label for="afterTo">To</label>
<input type="time" id="afterTo" class="form-control"
v-model="currentEditRecord.afterTo"
v-on:change="roundMinutes('afterTo'); validateTimeFields()">
</div>
<div style="flex: 1;">
<label for="afterBreak">Break Hours (Minutes)</label>
<div class="d-flex">
<select id="afterBreak" 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="airstationDropdown">Air Station</label>
<select id="airstationDropdown" 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="marinestationDropdown">Marine Station</label>
<select id="marinestationDropdown" 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="otDescription">Work Brief Description</label>
<textarea id="otDescription" class="form-control" v-model="currentEditRecord.otDescription" placeholder="Describe the work done..."></textarea>
<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="userFlexiHourDisplay">Your Flexi Hour</label>
<input type="text" id="userFlexiHourDisplay" 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="detectedDayType">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="totalOTHours">Total OT Hours</label>
<input type="text" id="totalOTHours" 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="totalBreakHours">Total Break Hours</label>
<input type="text" id="totalBreakHours" 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, // Keep this for existing logic, but use isApproverRole for salary visibility
expandedDescriptions: [],
currentEditRecord: {
overtimeId: null,
otDate: '',
officeFrom: '',
officeTo: '',
officeBreak: 0,
afterFrom: '',
afterTo: '',
afterBreak: 0,
stationId: null,
otDescription: '',
statusId: null,
otDays: ''
},
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;
}
}
},
watch: {
'currentEditRecord.otDate': function(newDate) {
},
'currentEditRecord.stationId': function(newVal) {
if (this.showAirStationDropdown) {
$('#airstationDropdown').val(newVal).trigger('change.select2');
} else if (this.showMarineStationDropdown) {
$('#marinestationDropdown').val(newVal).trigger('change.select2');
}
},
airStations: function() {
if (this.showAirStationDropdown) {
this.initSelect2('#airstationDropdown');
}
},
marineStations: function() {
if (this.showMarineStationDropdown) {
this.initSelect2('#marinestationDropdown');
}
}
},
methods: {
// New method to check if the current approver role is in the list of roles to hide salary info
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
};
// No change here for isHoU, as it's used for other logic (like edit/delete buttons)
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':
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 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 timeValue = this.currentEditRecord[fieldName];
if (!timeValue) return;
const [hours, minutes] = timeValue.split(':').map(Number);
let roundedMinutes = minutes;
if (minutes >= 45 || minutes < 15) {
roundedMinutes = 0;
if (minutes >= 45) {
let currentHour = hours;
currentHour = (currentHour + 1) % 24;
this.currentEditRecord[fieldName] = `${String(currentHour).padStart(2, '0')}:00`;
this.validateTimeFields();
return;
}
} else if (minutes >= 15 && minutes < 45) {
roundedMinutes = 30;
}
this.currentEditRecord[fieldName] = `${String(hours).padStart(2, '0')}:${String(roundedMinutes).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,
otDays: recordToEdit.otDays
};
this.$nextTick(() => {
if (this.showAirStationDropdown) {
this.initSelect2('#airstationDropdown');
$('#airstationDropdown').val(this.currentEditRecord.stationId).trigger('change.select2');
}
if (this.showMarineStationDropdown) {
this.initSelect2('#marinestationDropdown');
$('#marinestationDropdown').val(this.currentEditRecord.stationId).trigger('change.select2');
}
const editModal = new bootstrap.Modal(document.getElementById('editOtModal'));
editModal.show();
});
}
},
async submitEdit() {
try {
const hasOfficeHours = this.currentEditRecord.officeFrom && this.currentEditRecord.officeTo;
const hasAfterHours = this.currentEditRecord.afterFrom && this.currentEditRecord.afterTo;
if (!hasOfficeHours && !hasAfterHours) {
alert("Please enter either Office Hours or After Office Hours.");
return;
}
if (hasOfficeHours && !this.validateTimeRangeForSubmission(
this.currentEditRecord.officeFrom,
this.currentEditRecord.officeTo,
'Office Hour')) {
return;
}
if (hasAfterHours && !this.validateTimeRangeForSubmission(
this.currentEditRecord.afterFrom,
this.currentEditRecord.afterTo,
'After Office Hour')) {
return;
}
const response = await fetch('/OvertimeAPI/UpdateOtRecordByApprover', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.currentEditRecord)
});
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.');
}
},
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 || this.publicHolidays.length === 0) 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';
}
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) {
const [hours, minutes] = timeString.split(':').map(Number);
return { hours, minutes };
},
clearOfficeHours() {
this.currentEditRecord.officeFrom = "";
this.currentEditRecord.officeTo = "";
this.currentEditRecord.officeBreak = 0;
},
clearAfterHours() {
this.currentEditRecord.afterFrom = "";
this.currentEditRecord.afterTo = "";
this.currentEditRecord.afterBreak = 0;
},
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 < minAllowedFromMidnightTo || startMinutes > maxAllowedFromMidnightTo) {
alert(`Invalid ${label} Time: If 'To' is 12:00 am (00:00), 'From' must start between 4:30 pm and 11:30 pm on the same day to be saved.`);
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() {
const hasOfficeHours = this.currentEditRecord.officeFrom && this.currentEditRecord.officeTo;
const hasAfterHours = this.currentEditRecord.afterFrom && this.currentEditRecord.afterTo;
if (hasOfficeHours) {
this.validateTimeRangeForSubmission(
this.currentEditRecord.officeFrom,
this.currentEditRecord.officeTo,
'Office Hour');
}
if (hasAfterHours) {
this.validateTimeRangeForSubmission(
this.currentEditRecord.afterFrom,
this.currentEditRecord.afterTo,
'After Office Hour');
}
},
},
async created() {
await this.fetchOtRecords();
await this.fetchStations();
},
mounted() {
if (this.showAirStationDropdown) {
this.initSelect2('#airstationDropdown');
}
if (this.showMarineStationDropdown) {
this.initSelect2('#marinestationDropdown');
}
var editModalElement = document.getElementById('editOtModal');
if (editModalElement) {
editModalElement.addEventListener('shown.bs.modal', () => {
if (this.showAirStationDropdown) {
this.initSelect2('#airstationDropdown');
$('#airstationDropdown').val(this.currentEditRecord.stationId).trigger('change.select2');
}
if (this.showMarineStationDropdown) {
this.initSelect2('#marinestationDropdown');
$('#marinestationDropdown').val(this.currentEditRecord.stationId).trigger('change.select2');
}
});
}
}
});
reviewApp.mount("#reviewApp");
</script>
}