853 lines
40 KiB
Plaintext
853 lines
40 KiB
Plaintext
@{
|
|
ViewData["Title"] = "Overtime Status";
|
|
Layout = "~/Views/Shared/_Layout.cshtml";
|
|
}
|
|
|
|
<style>
|
|
.white-box {
|
|
background-color: white;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.filter-container {
|
|
background-color: #f8f9fa;
|
|
padding: 15px;
|
|
border-radius: 5px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.filter-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 15px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.filter-group {
|
|
flex: 1;
|
|
min-width: 200px;
|
|
}
|
|
|
|
.history-entry {
|
|
background-color: #f8f9fa;
|
|
border: 1px solid #e9ecef;
|
|
border-radius: 5px;
|
|
padding: 10px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.history-entry strong {
|
|
color: #343a40;
|
|
}
|
|
|
|
.history-entry em {
|
|
color: #6c757d;
|
|
}
|
|
|
|
.history-entry ul {
|
|
list-style: none;
|
|
padding-left: 0;
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.history-entry li {
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.sortable-header {
|
|
cursor: pointer;
|
|
position: relative;
|
|
padding-right: 20px;
|
|
}
|
|
|
|
.sortable-header:hover {
|
|
background-color: #f1f1f1;
|
|
}
|
|
|
|
.sortable-header::after {
|
|
content: "↕";
|
|
position: absolute;
|
|
right: 5px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
font-size: 12px;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.sortable-header.asc::after {
|
|
content: "↑";
|
|
opacity: 1;
|
|
}
|
|
|
|
.sortable-header.desc::after {
|
|
content: "↓";
|
|
opacity: 1;
|
|
}
|
|
|
|
.badge-status {
|
|
font-size: 0.85em;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.badge-pending {
|
|
background-color: #ffc107;
|
|
color: #212529;
|
|
}
|
|
|
|
.badge-approved {
|
|
background-color: #28a745;
|
|
color: white;
|
|
}
|
|
|
|
.badge-rejected {
|
|
background-color: #dc3545;
|
|
color: white;
|
|
}
|
|
</style>
|
|
|
|
<div id="app" style="max-width: 1300px; margin: auto; font-size: 13px;">
|
|
<div class="table-layer">
|
|
<div class="white-box">
|
|
|
|
<!-- Simplified Filter Section -->
|
|
<div class="filter-container">
|
|
<div class="row mb-3">
|
|
<div class="col-md-6">
|
|
<h5>Filters</h5>
|
|
</div>
|
|
<div class="col-md-6 text-end">
|
|
<button class="btn btn-sm btn-outline-secondary" v-on:click="resetFilters">
|
|
<i class="fas fa-redo"></i> Reset Filters
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="filter-row">
|
|
<div class="filter-group">
|
|
<label class="form-label">Month / Year</label>
|
|
<select class="form-select form-select-sm" v-model="filters.monthYear">
|
|
<option value="">All Months / Years</option>
|
|
<option v-for="(option, index) in monthYearOptions" :key="index" :value="option.value">
|
|
{{ option.text }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="filter-group">
|
|
<label class="form-label">Status</label>
|
|
<select class="form-select form-select-sm" v-model="filters.status">
|
|
<option value="">All Statuses</option>
|
|
<option v-for="(option, index) in statusOptions" :key="index" :value="option.value">
|
|
{{ option.text }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Table Section -->
|
|
<div class="table-container table-responsive">
|
|
<table id="otStatusTable" class="table table-bordered table-sm table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th class="sortable-header"
|
|
:class="{'asc': sort.field === 'monthYear' && sort.order === 'asc', 'desc': sort.field === 'monthYear' && sort.order === 'desc'}"
|
|
v-on:click="sortBy('monthYear')">Month/Year</th>
|
|
<th class="sortable-header"
|
|
:class="{'asc': sort.field === 'submitDate' && sort.order === 'asc', 'desc': sort.field === 'submitDate' && sort.order === 'desc'}"
|
|
v-on:click="sortBy('submitDate')">Submit Date</th>
|
|
<th v-if="includeHou" class="sortable-header"
|
|
:class="{'asc': sort.field === 'houStatus' && sort.order === 'asc', 'desc': sort.field === 'houStatus' && sort.order === 'desc'}"
|
|
v-on:click="sortBy('houStatus')">HoU Status</th>
|
|
<th v-if="includeHod" class="sortable-header"
|
|
:class="{'asc': sort.field === 'hodStatus' && sort.order === 'asc', 'desc': sort.field === 'hodStatus' && sort.order === 'desc'}"
|
|
v-on:click="sortBy('hodStatus')">HoD Status</th>
|
|
<th v-if="includeManager" class="sortable-header"
|
|
:class="{'asc': sort.field === 'managerStatus' && sort.order === 'asc', 'desc': sort.field === 'managerStatus' && sort.order === 'desc'}"
|
|
v-on:click="sortBy('managerStatus')">Manager Status</th>
|
|
<th v-if="includeHr" class="sortable-header"
|
|
:class="{'asc': sort.field === 'hrStatus' && sort.order === 'asc', 'desc': sort.field === 'hrStatus' && sort.order === 'desc'}"
|
|
v-on:click="sortBy('hrStatus')">HR Status</th>
|
|
<th>Edit History</th>
|
|
<th>File</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="(item, index) in filteredRecords" :key="index">
|
|
<td>{{ formatMonthYear(item.month, item.year) }}</td>
|
|
<td>{{ formatDate(item.submitDate) }}</td>
|
|
<td v-if="includeHou">
|
|
<span v-if="item.houStatus" :class="getStatusBadgeClass(item.houStatus)">
|
|
{{ item.houStatus }}
|
|
</span>
|
|
<span v-else>-</span>
|
|
</td>
|
|
<td v-if="includeHod">
|
|
<span v-if="item.hodStatus" :class="getStatusBadgeClass(item.hodStatus)">
|
|
{{ item.hodStatus }}
|
|
</span>
|
|
<span v-else>-</span>
|
|
</td>
|
|
<td v-if="includeManager">
|
|
<span v-if="item.managerStatus" :class="getStatusBadgeClass(item.managerStatus)">
|
|
{{ item.managerStatus }}
|
|
</span>
|
|
<span v-else>-</span>
|
|
</td>
|
|
<td v-if="includeHr">
|
|
<span v-if="item.hrStatus" :class="getStatusBadgeClass(item.hrStatus)">
|
|
{{ item.hrStatus }}
|
|
</span>
|
|
<span v-else>-</span>
|
|
</td>
|
|
<td>
|
|
<button v-if="item.updated" class="btn btn-sm btn-info" v-on:click="showEditHistory(item)">
|
|
<i class="fas fa-history"></i> View History
|
|
</button>
|
|
<span v-else>-</span>
|
|
</td>
|
|
<td>
|
|
<button v-if="item.filePath" class="btn btn-sm btn-primary" v-on:click="previewFile(item.filePath)">
|
|
<i class="fas fa-file-alt"></i> View
|
|
</button>
|
|
<span v-else>-</span>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="filteredRecords.length === 0">
|
|
<td :colspan="columnCount" class="text-center">No records found matching your criteria.</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- Pagination -->
|
|
<div class="row mt-3" v-if="filteredRecords.length > 0">
|
|
<div class="col-md-6">
|
|
<div class="dataTables_info">
|
|
Showing {{ pagination.startItem }} to {{ pagination.endItem }} of {{ filteredRecords.length }} entries
|
|
</div>
|
|
</div>
|
|
<div class="col-md-6">
|
|
<nav aria-label="Page navigation" class="float-end">
|
|
<ul class="pagination pagination-sm">
|
|
<li class="page-item" :class="{disabled: pagination.currentPage === 1}">
|
|
<a class="page-link" href="#" v-on:click.prevent="prevPage">« Previous</a>
|
|
</li>
|
|
<li class="page-item" v-for="page in pagination.totalPages" :key="page"
|
|
:class="{active: pagination.currentPage === page}">
|
|
<a class="page-link" href="#" v-on:click.prevent="goToPage(page)">{{ page }}</a>
|
|
</li>
|
|
<li class="page-item" :class="{disabled: pagination.currentPage === pagination.totalPages}">
|
|
<a class="page-link" href="#" v-on:click.prevent="nextPage">Next »</a>
|
|
</li>
|
|
</ul>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- File Preview Modal -->
|
|
<div class="modal fade" id="filePreviewModal" tabindex="-1" aria-labelledby="filePreviewModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-xl modal-dialog-centered">
|
|
|
|
<div class="modal-content border-0 shadow-lg rounded-3">
|
|
|
|
<div class="modal-header border-bottom-0 pb-0 d-flex justify-content-between align-items-center mt-2">
|
|
<div style="width: 150px;">
|
|
<h5 class="modal-title fw-bold text-dark" id="filePreviewModalLabel">
|
|
<i class="fas fa-file-alt me-2 text-primary"></i>Document
|
|
</h5>
|
|
</div>
|
|
|
|
<!-- Sleek Zoom Controls (for images) -->
|
|
<div v-if="isImage(previewUrl)" class="bg-light border rounded-pill p-1 mx-auto d-inline-flex align-items-center shadow-sm">
|
|
<button class="btn btn-sm btn-light rounded-circle border-0" v-on:click="zoomOut" title="Zoom Out">
|
|
<i class="fas fa-search-minus text-secondary"></i>
|
|
</button>
|
|
<span class="fw-bold text-secondary px-3 text-center" style="min-width: 65px; pointer-events: none; font-size: 14px;">
|
|
{{ Math.round(zoomLevel * 100) }}%
|
|
</span>
|
|
<button class="btn btn-sm btn-light rounded-circle border-0" v-on:click="resetZoom" title="Reset to Fit">
|
|
<i class="fas fa-expand text-secondary"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-light rounded-circle border-0" v-on:click="zoomIn" title="Zoom In">
|
|
<i class="fas fa-search-plus text-secondary"></i>
|
|
</button>
|
|
</div>
|
|
<div v-else class="mx-auto"></div>
|
|
|
|
<div style="width: 150px; text-align: right;">
|
|
<button type="button" class="btn-close shadow-none" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-body text-center bg-light mt-3" :class="isImage(previewUrl) ? 'p-4' : 'p-0'" style="height: 75vh; overflow: auto; position: relative;">
|
|
|
|
<template v-if="isImage(previewUrl)">
|
|
<!-- Keep the paper effect for standard images -->
|
|
<div style="display: inline-block; transition: transform 0.2s ease-in-out; transform-origin: top center;"
|
|
:style="{ transform: 'scale(' + zoomLevel + ')' }">
|
|
<img :src="previewUrl" class="bg-white p-1 border shadow-sm rounded" style="max-width: 100%; height: auto; display: block;" alt="File Preview" />
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<!-- Remove paper effect for PDFs -->
|
|
<iframe :src="previewUrl" width="100%" style="height: 100%; border: none; display: block;"></iframe>
|
|
</template>
|
|
|
|
</div>
|
|
|
|
<div class="modal-footer border-top-0 bg-light pt-0">
|
|
<button type="button" class="btn btn-secondary px-4 shadow-sm" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Edit History -->
|
|
<div class="modal fade" id="editHistoryModal" tabindex="-1" aria-labelledby="editHistoryModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
|
<div class="modal-content border-0 shadow">
|
|
|
|
<div class="modal-header border-bottom-0 pb-0">
|
|
<h5 class="modal-title fw-bold" id="editHistoryModalLabel">
|
|
<i class="fas fa-stream text-primary me-2"></i> Edit History
|
|
</h5>
|
|
<button type="button" class="btn-close shadow-none" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
|
|
<div class="modal-body p-4">
|
|
<p class="text-muted mb-4 ms-2">Record: <strong>{{ selectedRecord ? formatMonthYear(selectedRecord.month, selectedRecord.year) : '' }}</strong></p>
|
|
|
|
<div v-if="parsedHistory.length > 0" class="border-start border-2 border-secondary border-opacity-25 ms-3 ps-4 position-relative">
|
|
|
|
<!-- Timeline Node -->
|
|
<div v-for="(entry, historyIndex) in parsedHistory" :key="historyIndex" class="position-relative mb-5">
|
|
|
|
<!-- Node Dot -->
|
|
<span class="position-absolute translate-middle p-2 rounded-circle border border-2 border-white"
|
|
:class="entry.changes.some(c => c.field === 'Record Deletion') ? 'bg-danger' : 'bg-primary'"
|
|
style="top: 5px; left: -1.5rem !important;"></span>
|
|
|
|
<!-- Content -->
|
|
<div>
|
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
|
<h6 class="fw-bold mb-0 text-dark">
|
|
{{ entry.approvalRole }}
|
|
<span class="text-muted fw-normal fs-6">
|
|
<template v-if="entry.changeType === 'Delete'">
|
|
deleted the record
|
|
</template>
|
|
<template v-else>
|
|
updated the record for
|
|
<strong class="text-primary">{{ formatDate(entry.recordDate) }}</strong>
|
|
</template>
|
|
</span>
|
|
</h6>
|
|
<small class="text-muted"><i class="far fa-clock me-1"></i>{{ formatDateTime(entry.date) }}</small>
|
|
</div>
|
|
|
|
<!-- Changes Block -->
|
|
<div class="bg-light rounded-3 p-3 mt-2 border">
|
|
<div v-if="entry.changes && entry.changes.length > 0">
|
|
<div v-for="(change, changeIndex) in entry.changes" :key="changeIndex" class="mb-1" style="font-size: 0.9rem;">
|
|
|
|
<!-- Delete -->
|
|
<div v-if="change.field === 'Record Deletion'" class="text-danger">
|
|
<i class="fas fa-trash-alt me-2"></i> <strong>Record Removed:</strong> Date {{ change.before }}
|
|
</div>
|
|
|
|
<!-- Edit -->
|
|
<div v-else class="row g-0 align-items-center">
|
|
<div class="col-4 fw-semibold text-secondary">{{ change.field }}</div>
|
|
<div class="col-8 d-flex align-items-center">
|
|
<span class="text-muted text-decoration-line-through me-2">{{ change.before || 'Empty' }}</span>
|
|
<i class="fas fa-long-arrow-alt-right text-muted mx-2"></i>
|
|
<span class="text-dark fw-bold bg-white px-2 py-1 rounded border shadow-sm">{{ change.after || 'Empty' }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-muted fst-italic small">No explicit field changes detected.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else class="text-center py-5">
|
|
<i class="fas fa-history text-muted opacity-25 display-4 mb-3"></i>
|
|
<h6 class="text-muted">No modification history found.</h6>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modal-footer border-top-0">
|
|
<button type="button" class="btn btn-light border px-4" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
@section Scripts {
|
|
<script>
|
|
const app = Vue.createApp({
|
|
data() {
|
|
return {
|
|
otRecords: [],
|
|
includeHou: false,
|
|
includeHod: false,
|
|
includeManager: false,
|
|
includeHr: false,
|
|
previewUrl: '',
|
|
selectedRecord: null,
|
|
parsedHistory: [],
|
|
historyModalInstance: null,
|
|
filePreviewModalInstance: null,
|
|
stations: [],
|
|
zoomLevel: 1.0,
|
|
|
|
// Filtering data
|
|
filters: {
|
|
monthYear: '',
|
|
status: ''
|
|
},
|
|
|
|
// Sorting data
|
|
sort: {
|
|
field: 'submitDate',
|
|
order: 'desc'
|
|
},
|
|
|
|
// Pagination data
|
|
pagination: {
|
|
currentPage: 1,
|
|
itemsPerPage: 10,
|
|
totalPages: 1,
|
|
startItem: 1,
|
|
endItem: 10
|
|
}
|
|
};
|
|
},
|
|
|
|
computed: {
|
|
columnCount() {
|
|
let count = 3;
|
|
if (this.includeHou) count++;
|
|
if (this.includeHod) count++;
|
|
if (this.includeManager) count++;
|
|
if (this.includeHr) count++;
|
|
return count + 1;
|
|
},
|
|
|
|
monthYearOptions() {
|
|
const uniqueMonths = [...new Set(this.otRecords.map(item =>
|
|
`${item.month.toString().padStart(2, '0')}/${item.year}`
|
|
))];
|
|
return uniqueMonths.map(my => ({
|
|
value: my,
|
|
text: my
|
|
})).sort((a, b) => {
|
|
// Sort by year then month
|
|
const [aMonth, aYear] = a.value.split('/').map(Number);
|
|
const [bMonth, bYear] = b.value.split('/').map(Number);
|
|
return bYear - aYear || bMonth - aMonth;
|
|
});
|
|
},
|
|
|
|
statusOptions() {
|
|
const options = [
|
|
{ value: 'Pending', text: 'Pending' },
|
|
{ value: 'Approved', text: 'Approved' },
|
|
{ value: 'Rejected', text: 'Rejected' }
|
|
];
|
|
|
|
if ((this.includeHou && this.includeHod) ||
|
|
(this.includeHou && this.includeManager) ||
|
|
(this.includeHod && this.includeManager)) {
|
|
options.push(
|
|
{ value: 'PartiallyApproved', text: 'Partially Approved' },
|
|
{ value: 'FullyApproved', text: 'Fully Approved' }
|
|
);
|
|
}
|
|
|
|
return options;
|
|
},
|
|
|
|
filteredRecords() {
|
|
let filtered = [...this.otRecords];
|
|
|
|
if (this.filters.monthYear) {
|
|
const [month, year] = this.filters.monthYear.split('/').map(Number);
|
|
filtered = filtered.filter(item =>
|
|
item.month === month && item.year === year
|
|
);
|
|
}
|
|
|
|
if (this.filters.status) {
|
|
if (this.filters.status === 'PartiallyApproved') {
|
|
filtered = filtered.filter(item => {
|
|
const statuses = [];
|
|
if (this.includeHou) statuses.push(item.houStatus);
|
|
if (this.includeHod) statuses.push(item.hodStatus);
|
|
if (this.includeManager) statuses.push(item.managerStatus);
|
|
if (this.includeHr) statuses.push(item.hrStatus);
|
|
|
|
const approvedCount = statuses.filter(s => s === 'Approved').length;
|
|
const rejectedCount = statuses.filter(s => s === 'Rejected').length;
|
|
const pendingCount = statuses.filter(s => s === 'Pending' || !s).length;
|
|
|
|
return approvedCount > 0 && pendingCount > 0;
|
|
});
|
|
} else if (this.filters.status === 'FullyApproved') {
|
|
filtered = filtered.filter(item => {
|
|
const statuses = [];
|
|
if (this.includeHou) statuses.push(item.houStatus);
|
|
if (this.includeHod) statuses.push(item.hodStatus);
|
|
if (this.includeManager) statuses.push(item.managerStatus);
|
|
if (this.includeHr) statuses.push(item.hrStatus);
|
|
|
|
return statuses.every(s => s === 'Approved');
|
|
});
|
|
} else {
|
|
filtered = filtered.filter(item => {
|
|
if (this.includeHou && item.houStatus === this.filters.status) return true;
|
|
if (this.includeHod && item.hodStatus === this.filters.status) return true;
|
|
if (this.includeManager && item.managerStatus === this.filters.status) return true;
|
|
if (this.includeHr && item.hrStatus === this.filters.status) return true;
|
|
return false;
|
|
});
|
|
}
|
|
}
|
|
|
|
filtered.sort((a, b) => {
|
|
let aValue, bValue;
|
|
|
|
switch (this.sort.field) {
|
|
case 'monthYear':
|
|
aValue = new Date(a.year, a.month - 1);
|
|
bValue = new Date(b.year, b.month - 1);
|
|
break;
|
|
case 'submitDate':
|
|
aValue = new Date(a.submitDate);
|
|
bValue = new Date(b.submitDate);
|
|
break;
|
|
case 'houStatus':
|
|
aValue = a.houStatus || '';
|
|
bValue = b.houStatus || '';
|
|
break;
|
|
case 'hodStatus':
|
|
aValue = a.hodStatus || '';
|
|
bValue = b.hodStatus || '';
|
|
break;
|
|
case 'managerStatus':
|
|
aValue = a.managerStatus || '';
|
|
bValue = b.managerStatus || '';
|
|
break;
|
|
case 'hrStatus':
|
|
aValue = a.hrStatus || '';
|
|
bValue = b.hrStatus || '';
|
|
break;
|
|
default:
|
|
return 0;
|
|
}
|
|
|
|
if (aValue < bValue) return this.sort.order === 'asc' ? -1 : 1;
|
|
if (aValue > bValue) return this.sort.order === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
|
|
this.pagination.totalPages = Math.ceil(filtered.length / this.pagination.itemsPerPage);
|
|
this.pagination.currentPage = Math.min(this.pagination.currentPage, this.pagination.totalPages || 1);
|
|
|
|
const startIndex = (this.pagination.currentPage - 1) * this.pagination.itemsPerPage;
|
|
const endIndex = startIndex + this.pagination.itemsPerPage;
|
|
|
|
this.pagination.startItem = filtered.length > 0 ? startIndex + 1 : 0;
|
|
this.pagination.endItem = Math.min(endIndex, filtered.length);
|
|
|
|
return filtered.slice(startIndex, endIndex);
|
|
}
|
|
},
|
|
|
|
mounted() {
|
|
Promise.all([
|
|
fetch('/OvertimeAPI/GetUserOtStatus')
|
|
.then(res => {
|
|
if (!res.ok) {
|
|
throw new Error('Failed to fetch OT status');
|
|
}
|
|
return res.json();
|
|
})
|
|
.catch(error => {
|
|
console.error('Error fetching OT status:', error);
|
|
return {
|
|
includeHou: false,
|
|
includeHod: false,
|
|
includeManager: false,
|
|
includeHr: false,
|
|
otStatuses: [],
|
|
hasApprovalFlow: false
|
|
};
|
|
}),
|
|
fetch('/OvertimeAPI/GetAllStations').then(res => res.json())
|
|
])
|
|
.then(([otData, stationData]) => {
|
|
this.includeHou = otData.includeHou || false;
|
|
this.includeHod = otData.includeHod || false;
|
|
this.includeManager = otData.includeManager || false;
|
|
this.includeHr = otData.includeHr || false;
|
|
this.otRecords = (otData.otStatuses || []).map(item => ({
|
|
...item,
|
|
monthYear: `${item.month.toString().padStart(2, '0')}/${item.year}`
|
|
}));
|
|
|
|
if (otData.hasApprovalFlow === false) {
|
|
alert("Note: Your approval flow is not yet configured. Only basic information is shown.");
|
|
}
|
|
|
|
this.stations = stationData;
|
|
this.historyModalInstance = new bootstrap.Modal(document.getElementById('editHistoryModal'));
|
|
this.filePreviewModalInstance = new bootstrap.Modal(document.getElementById('filePreviewModal'));
|
|
})
|
|
.catch(error => {
|
|
console.error('Error initializing data:', error);
|
|
this.includeHou = false;
|
|
this.includeHod = false;
|
|
this.includeManager = false;
|
|
this.includeHr = false;
|
|
this.otRecords = [];
|
|
});
|
|
},
|
|
|
|
methods: {
|
|
formatDate(dateStr) {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
if (isNaN(date.getTime())) return '-';
|
|
return date.toLocaleDateString('en-MY', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
},
|
|
|
|
formatMonthYear(month, year) {
|
|
if (!month || !year) return '-';
|
|
return `${month.toString().padStart(2, '0')}/${year}`;
|
|
},
|
|
formatDateTime(dateStr) {
|
|
if (!dateStr) return '-';
|
|
const date = new Date(dateStr);
|
|
if (isNaN(date.getTime())) return '-';
|
|
|
|
return date.toLocaleString('en-MY', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: true // Uses AM/PM format
|
|
});
|
|
},
|
|
getStatusBadgeClass(status) {
|
|
if (!status) return '';
|
|
const statusLower = status.toLowerCase();
|
|
if (statusLower.includes('approve')) return 'badge badge-status badge-approved';
|
|
if (statusLower.includes('reject')) return 'badge badge-status badge-rejected';
|
|
return 'badge badge-status badge-pending';
|
|
},
|
|
|
|
isImage(url) {
|
|
if (!url) return false;
|
|
const lowerUrl = url.toLowerCase();
|
|
return lowerUrl.endsWith('.jpg') || lowerUrl.endsWith('.jpeg') ||
|
|
lowerUrl.endsWith('.png') || lowerUrl.endsWith('.gif') ||
|
|
lowerUrl.endsWith('.webp');
|
|
},
|
|
|
|
zoomIn() {
|
|
if (this.zoomLevel < 3) this.zoomLevel += 0.2;
|
|
},
|
|
zoomOut() {
|
|
if (this.zoomLevel > 0.4) this.zoomLevel -= 0.2;
|
|
},
|
|
resetZoom() {
|
|
this.zoomLevel = 1.0;
|
|
},
|
|
|
|
previewFile(path) {
|
|
this.previewUrl = '/' + path.replace(/^\/+/, '');
|
|
if (this.filePreviewModalInstance) {
|
|
this.filePreviewModalInstance.show();
|
|
}
|
|
},
|
|
|
|
getStationNameById(stationId) {
|
|
if (!stationId) return 'N/A';
|
|
const station = this.stations.find(s => s.stationId === stationId);
|
|
return station ? station.stationName : `Unknown Station (ID: ${stationId})`;
|
|
},
|
|
|
|
formatMinutesToHours(minutes) {
|
|
if (minutes === null || minutes === undefined || isNaN(minutes)) {
|
|
return '';
|
|
}
|
|
if (minutes < 0) return 'Invalid (Negative)';
|
|
|
|
const hours = Math.floor(minutes / 60);
|
|
const remainingMinutes = minutes % 60;
|
|
|
|
const formattedHours = String(hours).padStart(1, '0');
|
|
const formattedMinutes = String(remainingMinutes).padStart(2, '0');
|
|
|
|
return `${formattedHours}:${formattedMinutes}`;
|
|
},
|
|
|
|
getChanges(before, after) {
|
|
const changes = [];
|
|
// If there is no before/after data, return empty
|
|
if (!before || !after) return changes;
|
|
|
|
// Loop through ONLY the keys provided by the clean JSON
|
|
for (const key in after) {
|
|
if (Object.prototype.hasOwnProperty.call(after, key)) {
|
|
let formattedBefore = before[key];
|
|
let formattedAfter = after[key];
|
|
|
|
// Resolve Station IDs to Station Names
|
|
if (key === 'StationId') {
|
|
formattedBefore = formattedBefore !== "Empty" ? this.getStationNameById(parseInt(formattedBefore)) : "Empty";
|
|
formattedAfter = formattedAfter !== "Empty" ? this.getStationNameById(parseInt(formattedAfter)) : "Empty";
|
|
}
|
|
// Format Breaks correctly (e.g., 60 minutes -> 1:00)
|
|
else if (['OfficeBreak', 'AfterBreak'].includes(key)) {
|
|
formattedBefore = formattedBefore !== "Empty" ? this.formatMinutesToHours(formattedBefore) : "0:00";
|
|
formattedAfter = formattedAfter !== "Empty" ? this.formatMinutesToHours(formattedAfter) : "0:00";
|
|
}
|
|
else if (key === 'OtDate') {
|
|
formattedBefore = formattedBefore !== "Empty" ? this.formatDate(formattedBefore) : "Empty";
|
|
formattedAfter = formattedAfter !== "Empty" ? this.formatDate(formattedAfter) : "Empty";
|
|
}
|
|
|
|
changes.push({
|
|
field: key,
|
|
before: formattedBefore,
|
|
after: formattedAfter
|
|
});
|
|
}
|
|
}
|
|
return changes;
|
|
},
|
|
|
|
showEditHistory(record) {
|
|
this.selectedRecord = record;
|
|
this.parsedHistory = [];
|
|
|
|
const updateFields = ['houUpdate', 'hodUpdate', 'managerUpdate', 'hrUpdate'];
|
|
const approvalRoles = {
|
|
'houUpdate': 'HoU',
|
|
'hodUpdate': 'HoD',
|
|
'managerUpdate': 'Manager',
|
|
'hrUpdate': 'HR'
|
|
};
|
|
|
|
updateFields.forEach(field => {
|
|
if (record[field]) {
|
|
try {
|
|
const updateLogArray = JSON.parse(record[field]);
|
|
|
|
if (Array.isArray(updateLogArray)) {
|
|
updateLogArray.forEach(logEntry => {
|
|
let changesDetail = [];
|
|
|
|
if (logEntry.ChangeType === 'Edit') {
|
|
if (logEntry.BeforeEdit && logEntry.AfterEdit) {
|
|
changesDetail = this.getChanges(logEntry.BeforeEdit, logEntry.AfterEdit);
|
|
}
|
|
} else if (logEntry.ChangeType === 'Delete') {
|
|
if (logEntry.DeletedRecord) {
|
|
changesDetail.push({
|
|
field: 'Record Deletion',
|
|
before: this.formatDate(logEntry.DeletedRecord.OtDate),
|
|
after: 'Record Deleted'
|
|
});
|
|
} else {
|
|
changesDetail.push({ field: 'Record Deletion', before: 'Unknown Record', after: 'Record Deleted' });
|
|
}
|
|
}
|
|
|
|
this.parsedHistory.push({
|
|
approvedBy: logEntry.ApproverRole || 'N/A',
|
|
approvalRole: logEntry.ApproverRole || approvalRoles[field],
|
|
date: logEntry.UpdateTimestamp || new Date().toISOString(),
|
|
recordDate: logEntry.RecordDate,
|
|
changeType: logEntry.ChangeType,
|
|
changes: changesDetail
|
|
});
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error(`Error parsing JSON for ${field}:`, e, record[field]);
|
|
}
|
|
}
|
|
});
|
|
|
|
this.parsedHistory.sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
|
|
if (this.historyModalInstance) {
|
|
this.historyModalInstance.show();
|
|
}
|
|
},
|
|
|
|
closeEditHistory() {
|
|
if (this.historyModalInstance) {
|
|
this.historyModalInstance.hide();
|
|
}
|
|
this.selectedRecord = null;
|
|
this.parsedHistory = [];
|
|
},
|
|
|
|
sortBy(field) {
|
|
if (this.sort.field === field) {
|
|
this.sort.order = this.sort.order === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
this.sort.field = field;
|
|
this.sort.order = 'asc';
|
|
}
|
|
this.pagination.currentPage = 1;
|
|
},
|
|
|
|
resetFilters() {
|
|
this.filters = {
|
|
monthYear: '',
|
|
status: ''
|
|
};
|
|
this.pagination.currentPage = 1;
|
|
},
|
|
|
|
prevPage() {
|
|
if (this.pagination.currentPage > 1) {
|
|
this.pagination.currentPage--;
|
|
}
|
|
},
|
|
|
|
nextPage() {
|
|
if (this.pagination.currentPage < this.pagination.totalPages) {
|
|
this.pagination.currentPage++;
|
|
}
|
|
},
|
|
|
|
goToPage(page) {
|
|
if (page >= 1 && page <= this.pagination.totalPages) {
|
|
this.pagination.currentPage = page;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
app.mount('#app');
|
|
</script>
|
|
} |