1320 lines
69 KiB
Plaintext
1320 lines
69 KiB
Plaintext
@{
|
|
ViewData["Title"] = "OT Details Review";
|
|
Layout = "~/Views/Shared/_Layout.cshtml";
|
|
}
|
|
|
|
<style>
|
|
/* Your existing styles (excluding the previously added .date-column specific styles) */
|
|
.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="!isHoU">Basic Salary<br>(RM)</th>
|
|
<th class="header-orange" rowspan="2" v-if="!isHoU">ORP</th>
|
|
<th class="header-orange" rowspan="2" v-if="!isHoU">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="!isHoU">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 < 4hrs</th>
|
|
<th class="header-blue">OD Within office hrs > 4hrs < 8 hrs</th>
|
|
<th class="header-blue">OD After office hrs</th>
|
|
<th class="header-blue">RD Within office hrs < 4hrs</th>
|
|
<th class="header-blue">RD Within office hrs > 4hrs < 8 hrs</th>
|
|
<th class="header-blue">RD After office hrs</th>
|
|
<th class="header-blue">PH Within office hrs < 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 && !isHoU">
|
|
<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="!isHoU">{{ 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 ? (isHoU ? 27 : 31) : (isHoU ? 26 : 30)">No overtime details found for this submission.</td>
|
|
</tr>
|
|
</tbody>
|
|
<tfoot>
|
|
<tr class="fw-bold bg-light">
|
|
<td v-if="!isHoU" colspan="3">TOTAL</td>
|
|
<td v-else colspan="1">TOTAL</td>
|
|
|
|
<td v-if="!isHoU" colspan="3"></td>
|
|
<td v-else colspan="2"></td>
|
|
<td><strong>{{ formatBreakToHourMinute(totals.officeBreak) }}</strong></td>
|
|
|
|
<td v-if="!isHoU" colspan="2"></td>
|
|
<td v-else colspan="2"></td>
|
|
<td><strong>{{ formatBreakToHourMinute(totals.afterBreak) }}</strong></td>
|
|
|
|
<td v-if="!isHoU" 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="!isHoU">{{ 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 // This will now hold the Basic Salary retrieved from API
|
|
},
|
|
isHoU: false,
|
|
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);
|
|
|
|
// Breaks are already in minutes, no need for parseFloat and then toFixed for summing
|
|
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;
|
|
|
|
// Ensure these values are numeric before summing
|
|
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) {
|
|
// Apply standard rounding (toNearestEven, like C# decimal.ToString("N2"))
|
|
// Only apply to total amounts, individual cell values are formatted as needed
|
|
if (key.endsWith('Hrs') || key.endsWith('Amt')) { // Target hour and amount totals
|
|
totals[key] = totals[key].toFixed(2);
|
|
} else if (key.endsWith('Break')) { // Break totals remain in minutes
|
|
// Already summed as integers/floats, format later if needed for display
|
|
} else { // Classified hours (ndAfter, odUnder4 etc.) should be toFixed(2)
|
|
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) {
|
|
// This watcher will trigger the getDayType computed property indirectly
|
|
// ensuring the 'Day' input in the modal updates reactively.
|
|
// No explicit action needed here, as the computed property handles it.
|
|
},
|
|
// Watch for changes in stationId to update Select2
|
|
'currentEditRecord.stationId': function(newVal) {
|
|
if (this.showAirStationDropdown) {
|
|
$('#airstationDropdown').val(newVal).trigger('change.select2');
|
|
} else if (this.showMarineStationDropdown) {
|
|
$('#marinestationDropdown').val(newVal).trigger('change.select2');
|
|
}
|
|
},
|
|
// Watch for changes in airStations and marineStations to update Select2 options
|
|
airStations: function() {
|
|
if (this.showAirStationDropdown) {
|
|
this.initSelect2('#airstationDropdown');
|
|
}
|
|
},
|
|
marineStations: function() {
|
|
if (this.showMarineStationDropdown) {
|
|
this.initSelect2('#marinestationDropdown');
|
|
}
|
|
}
|
|
},
|
|
methods: {
|
|
// REMOVED: Custom rounding function (roundUpToTwoDecimals) as C# uses standard rounding
|
|
// and we'll use toFixed(2) directly for consistency.
|
|
|
|
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;
|
|
// Assuming data.userInfo.rate now holds the Basic Salary
|
|
this.userInfo = {
|
|
...data.userInfo,
|
|
basicSalary: data.userInfo.rate // Assign the API's 'rate' (which is now Basic Salary) to basicSalary
|
|
};
|
|
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) {
|
|
// Destroy existing Select2 instance if any
|
|
if ($(selector).data('select2')) {
|
|
$(selector).select2('destroy');
|
|
}
|
|
|
|
// Initialize Select2
|
|
$(selector).select2({
|
|
dropdownParent: $('#editOtModal') // Ensure dropdown is within the modal boundaries
|
|
}).on('change', (e) => {
|
|
// Update Vue data when Select2 value changes
|
|
this.currentEditRecord.stationId = $(e.currentTarget).val();
|
|
});
|
|
|
|
// Set the initial value if currentEditRecord.stationId is already set
|
|
// This needs to be called after options are populated and Select2 is initialized
|
|
if (this.currentEditRecord.stationId) {
|
|
$(selector).val(this.currentEditRecord.stationId).trigger('change.select2');
|
|
}
|
|
},
|
|
// HRP = ORP / 8
|
|
calculateHrp() {
|
|
const orp = parseFloat(this.calculateOrp()); // Get ORP from the calculated value
|
|
if (isNaN(orp) || orp <= 0) return 'N/A';
|
|
return (orp / 8).toFixed(2);
|
|
},
|
|
// ORP = Basic Salary / 26
|
|
calculateOrp() {
|
|
const basicSalary = parseFloat(this.userInfo.basicSalary);
|
|
if (isNaN(basicSalary) || basicSalary <= 0) return 'N/A';
|
|
return (basicSalary / 26).toFixed(2);
|
|
},
|
|
// Basic Salary will now directly come from userInfo.basicSalary
|
|
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); // Round minutes to nearest integer
|
|
return `${hrs.toString().padStart(1, '0')}:${mins.toString().padStart(2, '0')}`;
|
|
},
|
|
// New helper to format decimal hours into H:MM string for display columns
|
|
formatTimeFromDecimal(decimalHours) {
|
|
if (decimalHours === null || isNaN(decimalHours) || decimalHours <= 0) return '';
|
|
const totalMinutes = Math.round(decimalHours * 60); // Convert to minutes and round
|
|
const hours = Math.floor(totalMinutes / 60);
|
|
const minutes = totalMinutes % 60;
|
|
return `${hours}:${minutes.toString().padStart(2, '0')}`;
|
|
},
|
|
// Renamed from convertTimeToDecimal to better reflect its purpose: parsing "HH:MM" to decimal hours
|
|
parseTimeSpanToDecimalHours(timeStr) {
|
|
if (!timeStr || typeof timeStr !== 'string') return 0; // Return 0 for empty/invalid
|
|
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);
|
|
},
|
|
// This function now returns decimal hours, consistent with C# `TotalHours`
|
|
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; // Handle overnight OT
|
|
|
|
return Math.max(0, totalMinutes / 60); // Return hours as decimal
|
|
},
|
|
|
|
// calculateOfficeOtHours and calculateAfterOfficeOtHours now call the new formatTimeFromDecimal
|
|
// to display H:MM format, but internally use calculateRawDuration which returns decimal hours.
|
|
// The C# version also returns H:MM strings.
|
|
// This also ensures 'OT Hrs (Office Hour)' column is blank for Normal Day.
|
|
// (Original C# `CalculateOfficeOtHours` also had the `totalMinutes = Math.Max(0, totalMinutes);` safeguard.)
|
|
// (Original C# `CalculateAfterOfficeOtHours` also had the `totalMinutes = Math.Max(0, totalMinutes);` safeguard.)
|
|
|
|
// No changes needed for these methods themselves, as they now correctly use `formatTimeFromDecimal`
|
|
// and `calculateRawDuration`.
|
|
// The `if (record.dayType === 'Normal Day') { return ''; }` part remains for display purposes,
|
|
// matching the C# PDF's decision to show empty for normal day office OT.
|
|
calculateOfficeOtHours(record) {
|
|
if (!record.officeFrom || !record.officeTo) return '';
|
|
if (record.dayType === 'Normal Day') { // Added as per PDF logic
|
|
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);
|
|
|
|
// Changed toFixed(2) to directly return number if num > 0, then toFixed(2) when used in table cells
|
|
// This aligns with C# returning decimal and then formatting later.
|
|
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': // Keep 'Weekend' here, it correctly maps to Off Day/Rest Day via getDayType
|
|
if (officeHrs > 0) {
|
|
// C# puts entire officeHrs into odAfter/rdAfter if > 8.
|
|
// It seems the column mapping implies odUnder4 (<=4), odBetween4And8 (>4, <=8), odAfter (>8) for office hrs,
|
|
// AND also after office hrs go to odAfter.
|
|
if (officeHrs <= 4) {
|
|
result.odUnder4 = toFixedOrEmpty(officeHrs);
|
|
} else if (officeHrs <= 8) { // C# uses else if here, covering > 4 and <= 8
|
|
result.odBetween4And8 = toFixedOrEmpty(officeHrs);
|
|
} else { // For officeHrs > 8. C# puts all officeHrs into odAfter
|
|
result.odAfter = toFixedOrEmpty(officeHrs); // Changed to match C# logic directly
|
|
}
|
|
}
|
|
// C# then adds afterHrs to odAfter.
|
|
result.odAfter = toFixedOrEmpty( (parseFloat(result.odAfter) || 0) + afterHrs ); // Ensure numeric addition
|
|
|
|
break;
|
|
|
|
case 'Rest Day':
|
|
if (officeHrs > 0) {
|
|
if (officeHrs <= 4) {
|
|
result.rdUnder4 = toFixedOrEmpty(officeHrs);
|
|
} else if (officeHrs <= 8) { // C# uses else if here, covering > 4 and <= 8
|
|
result.rdBetween4And8 = toFixedOrEmpty(officeHrs);
|
|
} else { // For officeHrs > 8. C# puts all officeHrs into rdAfter
|
|
result.rdAfter = toFixedOrEmpty(officeHrs); // Changed to match C# logic directly
|
|
}
|
|
}
|
|
// C# then adds afterHrs to rdAfter.
|
|
result.rdAfter = toFixedOrEmpty( (parseFloat(result.rdAfter) || 0) + afterHrs ); // Ensure numeric addition
|
|
break;
|
|
|
|
case 'Public Holiday':
|
|
// C# logic: if officeHrs <= 8, it's phUnder8. ELSE (if officeHrs > 8) it's phAfter.
|
|
// Then afterHrs are added to phAfter.
|
|
if (officeHrs > 0) {
|
|
if (officeHrs <= 8) {
|
|
result.phUnder8 = toFixedOrEmpty(officeHrs);
|
|
} else { // officeHrs > 8
|
|
result.phAfter = toFixedOrEmpty(officeHrs); // Changed to match C# logic
|
|
}
|
|
}
|
|
// C# then adds afterHrs to phAfter.
|
|
result.phAfter = toFixedOrEmpty( (parseFloat(result.phAfter) || 0) + afterHrs ); // Ensure numeric addition
|
|
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); // Keep .toFixed(2) for display
|
|
},
|
|
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') { // Removed 'Weekend' here, GetDayType handles it
|
|
if (officeHours <= 4) {
|
|
amountOffice = 0.5 * orp;
|
|
} else if (officeHours <= 8) {
|
|
amountOffice = 1 * orp;
|
|
}
|
|
// C# code logic does NOT have an else if (officeHours > 8) here.
|
|
// If officeHours > 8, it would fall through and use the last matching condition, which for Off/Rest day is `1 * orp;`
|
|
// So, this is the C# logic for office hours on Off/Rest days:
|
|
// The C# code doesn't explicitly state what happens for officeHours > 8 on Off/Rest Day.
|
|
// Based on the C# code, the `CalculateOtAmount` method for 'Off Day' and 'Rest Day' has:
|
|
// `if (officeHours <= 4) { amountOffice = 0.5m * orp; }`
|
|
// `else if (officeHours > 4 && officeHours <= 8) { amountOffice = 1 * orp; }`
|
|
// There is *no `else` branch* for `officeHours > 8`. This means if `officeHours` is 9, it would still get `1 * orp`.
|
|
// This indicates that the PDF's logic currently only defines fixed amounts for <=4h and >4h<=8h.
|
|
// To perfectly match PDF, the JS should also stop calculating `amountOffice` after `1 * orp` for >8 hours on Off/Rest.
|
|
// This confirms the previous analysis: C# does not have a special rule for `officeHours > 8` on Off/Rest days.
|
|
} else if (otType === 'Public Holiday') {
|
|
// C# logic: `amountOffice = 2 * orp;` without conditions for PH Office Hours.
|
|
// This means it's a fixed 2 * ORP regardless of hours.
|
|
amountOffice = 2 * orp; // Match C# logic directly
|
|
}
|
|
}
|
|
|
|
if (afterOfficeHours > 0) {
|
|
switch (otType) {
|
|
case 'Normal Day':
|
|
case 'Off Day': // Changed to match C# logic (single case for ND and Off Day after-office hours)
|
|
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); // Use toFixed(2) for standard rounding
|
|
},
|
|
// --- NEW METHOD FOR ROUNDING MINUTES ---
|
|
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) { // If minutes are 45-59, round up to next hour (00 minutes)
|
|
let currentHour = hours;
|
|
currentHour = (currentHour + 1) % 24; // Handle 23:xx rounding to 00:00
|
|
this.currentEditRecord[fieldName] = `${String(currentHour).padStart(2, '0')}:00`;
|
|
// No need to call validateTimeFields here, as it's called after the if condition
|
|
// and it will be called for all cases after rounding.
|
|
this.validateTimeFields(); // Still call here to update immediately if input changes
|
|
return; // Exit after setting
|
|
}
|
|
} else if (minutes >= 15 && minutes < 45) {
|
|
roundedMinutes = 30;
|
|
}
|
|
|
|
this.currentEditRecord[fieldName] = `${String(hours).padStart(2, '0')}:${String(roundedMinutes).padStart(2, '0')}`;
|
|
|
|
// Trigger validation after rounding
|
|
this.validateTimeFields();
|
|
},
|
|
// --- END NEW METHOD ---
|
|
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
|
|
};
|
|
|
|
// Initialize Select2 after Vue has updated the DOM with the new data
|
|
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 {
|
|
// Validate time ranges
|
|
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: 'PUT',
|
|
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';
|
|
}
|
|
|
|
// WeekendId 1: Friday (5) & Saturday (6)
|
|
// WeekendId 2: Saturday (6) & Sunday (0)
|
|
if (this.userState.weekendId === 1) {
|
|
if (dayOfWeek === 5) return 'Off Day'; // Friday
|
|
if (dayOfWeek === 6) return 'Rest Day'; // Saturday
|
|
else return 'Normal Day';
|
|
} else if (this.userState.weekendId === 2) {
|
|
if (dayOfWeek === 6) return 'Off Day'; // Saturday
|
|
if (dayOfWeek === 0) return 'Rest Day'; // Sunday
|
|
else return 'Normal Day';
|
|
}
|
|
|
|
return 'Normal Day'; // Default if not a public holiday or weekend
|
|
},
|
|
|
|
calculateModalTotalOtHrs() {
|
|
// Use calculateRawDuration which returns decimal hours
|
|
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); // Total minutes from decimal hours
|
|
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; // Handled by outer checks
|
|
|
|
const start = this.parseTime(fromTime);
|
|
const end = this.parseTime(toTime);
|
|
|
|
const minAllowedFromMinutesForMidnightTo = 16 * 60 + 30; // 4:30 PM
|
|
const maxAllowedFromMinutesForMidnightTo = 23 * 60 + 30; // 11:30 PM
|
|
const startMinutes = start.hours * 60 + start.minutes;
|
|
|
|
if (end.hours === 0 && end.minutes === 0) { // If 'To' is 00:00 (midnight)
|
|
if (fromTime === "00:00") {
|
|
alert(`Invalid ${label} Time: 'From' and 'To' cannot both be 00:00 (midnight).`);
|
|
return false;
|
|
}
|
|
// This is the specific rule: if 'To' is 00:00, 'From' must be within 4:30 PM and 11:30 PM
|
|
if (startMinutes < minAllowedFromMinutesForMidnightTo || startMinutes > maxAllowedFromMinutesForMidnightTo) {
|
|
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() {
|
|
// This will validate the fields whenever they change
|
|
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() {
|
|
// Initialize Select2 when the component is mounted
|
|
// These will apply to the elements if they are visible initially based on userInfo
|
|
if (this.showAirStationDropdown) {
|
|
this.initSelect2('#airstationDropdown');
|
|
}
|
|
if (this.showMarineStationDropdown) {
|
|
this.initSelect2('#marinestationDropdown');
|
|
}
|
|
|
|
// Re-initialize Select2 when the modal is shown
|
|
var editModalElement = document.getElementById('editOtModal');
|
|
if (editModalElement) {
|
|
editModalElement.addEventListener('shown.bs.modal', () => {
|
|
// Re-initialize Select2 and set its value when the modal becomes visible
|
|
// This ensures Select2 is active and displays the correct value when the modal opens
|
|
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>
|
|
} |