PSTW_CentralizeSystem/Areas/OTcalculate/Views/ApprovalDashboard/Approval.cshtml
2025-06-11 10:30:45 +08:00

458 lines
20 KiB
Plaintext

@{
ViewData["Title"] = "Overtime Pending Approval";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<style>
body {
background-color: #f3f4f6;
font-family: Arial, sans-serif;
}
.table-layer {
background-color: #fff;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 25px;
margin-top: 20px;
border: 1px solid #e0e0e0;
}
.table-container table {
width: 100%;
margin-bottom: 0;
}
.header {
background-color: #007bff;
color: white;
text-align: center;
vertical-align: middle;
}
.btn-sm {
font-size: 0.75rem;
}
.badge-pending {
background-color: #ffc107;
color: #343a40;
}
.badge-approved {
background-color: #28a745;
color: white;
}
.badge-rejected {
background-color: #dc3545;
color: white;
}
.badge-secondary {
background-color: #6c757d;
color: white;
}
.action-buttons button {
margin-right: 5px;
}
.action-buttons button:last-child {
margin-right: 0;
}
.sortable-header {
cursor: pointer;
user-select: none;
position: relative;
padding-right: 20px;
}
.sortable-header .sort-icon {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
font-size: 0.8em;
}
.pagination-controls {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-top: 1px solid #e9ecef;
margin-top: 15px;
}
.pagination-info {
font-size: 0.85em;
color: #555;
margin-right: 15px;
}
.pagination-items-per-page {
display: flex;
align-items: center;
}
.pagination-items-per-page label {
white-space: nowrap;
}
.formal-pending-notice {
margin-top: 15px;
margin-bottom: 20px;
padding: 12px 20px;
border-radius: 8px;
background-color: #f8d7da;
border: 1px solid #dc3545;
color: #721c24;
font-weight: bold;
text-align: left;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
</style>
<div id="app" style="max-width: 1300px; margin: auto; font-size: 13px;">
<div class="row mb-3 align-items-end">
<div class="col-md-auto me-3">
<label for="monthSelect" class="form-label mb-1">Month</label>
<select id="monthSelect" class="form-control form-control-sm" v-model="selectedMonth" v-on:change="loadData">
<option v-for="(m, i) in months" :value="i + 1">{{ m }}</option>
</select>
</div>
<div class="col-md-auto">
<label for="yearSelect" class="form-label mb-1">Year</label>
<select id="yearSelect" class="form-control form-control-sm" v-model="selectedYear" v-on:change="loadData">
<option v-for="y in years" :value="y">{{ y }}</option>
</select>
</div>
</div>
<div v-if="overallPendingMonths.length > 0 && activeTab === 'pending'" class="formal-pending-notice">
<b>Pending Action :</b> {{ overallPendingMonths.join(', ') }}
</div>
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link" :class="{ active: activeTab === 'pending' }" v-on:click="activeTab = 'pending'" href="#">
Pending Actions <span class="badge bg-warning text-dark">{{ pendingActionsCount }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" :class="{ active: activeTab === 'completed' }" v-on:click="activeTab = 'completed'" href="#">
Completed Actions <span class="badge bg-info">{{ completedActionsCount }}</span>
</a>
</li>
</ul>
<div class="table-layer">
<div class="mb-3">
<input type="text" class="form-control form-control-sm" placeholder="Search by Staff Name or Status..." v-model="searchQuery" />
</div>
<div class="table-container table-responsive">
<table class="table table-bordered table-sm table-striped">
<thead>
<tr>
<th class="header sortable-header" v-on:click="sortBy('fullName')">
Staff Name
<span class="sort-icon">
<template v-if="sortByColumn === 'fullName'">
{{ sortDirection === 'asc' ? '&#9650;' : '&#9660;' }}
</template>
</span>
</th>
<th class="header sortable-header" v-on:click="sortBy('submitDate')">
Date Submit
<span class="sort-icon">
<template v-if="sortByColumn === 'submitDate'">
{{ sortDirection === 'asc' ? '&#9650;' : '&#9660;' }}
</template>
</span>
</th>
<th class="header sortable-header" v-on:click="sortBy('currentUserStatus')">
Status
<span class="sort-icon">
<template v-if="sortByColumn === 'currentUserStatus'">
{{ sortDirection === 'asc' ? '&#9650;' : '&#9660;' }}
</template>
</span>
</th>
<th class="header">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="row in paginatedData" :key="row.statusId">
<td>{{ row.fullName }}</td>
<td>{{ formatDate(row.submitDate) }}</td>
<td>
<div v-if="row.role === 'HoU'">HoU: <span :class="getStatusBadgeClass(row.houStatus)">{{ row.houStatus }}</span></div>
<div v-else-if="row.role === 'HoD'">HoD: <span :class="getStatusBadgeClass(row.hodStatus)">{{ row.hodStatus }}</span></div>
<div v-else-if="row.role === 'Manager'">Manager: <span :class="getStatusBadgeClass(row.managerStatus)">{{ row.managerStatus }}</span></div>
<div v-else-if="row.role === 'HR'">HR: <span :class="getStatusBadgeClass(row.hrStatus)">{{ row.hrStatus }}</span></div>
<div v-if="row.IsOverallRejected && (row.currentUserStatus !== 'Approved' && row.currentUserStatus !== 'Rejected')" class="mt-1">
<span class="badge bg-danger">Rejected by a previous approver</span>
</div>
</td>
<td>
<div class="d-flex align-items-center justify-content-center action-buttons">
<template v-if="activeTab === 'pending'">
<template v-if="row.canApprove">
<button class="btn btn-success btn-sm" v-on:click="updateStatus(row.statusId, 'Approved')">Approve</button>
<button class="btn btn-danger btn-sm" v-on:click="updateStatus(row.statusId, 'Rejected')">Reject</button>
</template>
<template v-else-if="!row.canApprove && row.IsOverallRejected">
<span class="badge bg-danger">Rejected</span>
</template>
<template v-else-if="!row.canApprove">
<span class="badge bg-secondary">Awaiting previous approval</span>
</template>
</template>
<template v-else-if="activeTab === 'completed'"></template>
<button class="btn btn-primary btn-sm" v-on:click="viewOtData(row.statusId)">View</button>
</div>
</td>
</tr>
<tr v-if="paginatedData.length === 0">
<td colspan="4" class="text-center">No {{ activeTab === 'pending' ? 'pending' : 'completed' }} actions found for your current filters.</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination-controls">
<div class="pagination-info">
Showing {{ (currentPage - 1) * itemsPerPage + 1 }} to {{ Math.min(currentPage * itemsPerPage, filteredAndSortedOtStatusList.length) }} of {{ filteredAndSortedOtStatusList.length }} entries
</div>
<nav aria-label="Page navigation" class="mx-auto">
<ul class="pagination pagination-sm mb-0">
<li class="page-item" :class="{ disabled: currentPage === 1 }">
<a class="page-link" href="#" v-on:click.prevent="currentPage--" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
<li class="page-item" v-for="page in totalPages" :key="page" :class="{ active: currentPage === page }">
<a class="page-link" href="#" v-on:click.prevent="currentPage = page">{{ page }}</a>
</li>
<li class="page-item" :class="{ disabled: currentPage === totalPages }">
<a class="page-link" href="#" v-on:click.prevent="currentPage++" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
<div class="pagination-items-per-page">
<label class="me-2 mb-0">Items per page:</label>
<select class="form-select form-select-sm" v-model.number="itemsPerPage">
<option :value="5">5</option>
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="filteredAndSortedOtStatusList.length">All</option>
</select>
</div>
</div>
</div>
</div>
<script>
const app = Vue.createApp({
data() {
return {
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
years: Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - 5 + i),
selectedMonth: parseInt(sessionStorage.getItem('approvalSelectedMonth')) || (new Date().getMonth() + 1),
selectedYear: parseInt(sessionStorage.getItem('approvalSelectedYear')) || new Date().getFullYear(),
otStatusList: [],
activeTab: 'pending',
userRoles: [],
sortByColumn: 'submitDate',
sortDirection: 'desc',
searchQuery: '',
currentPage: 1,
itemsPerPage: 10,
overallPendingMonths: []
};
},
watch: {
activeTab() {
this.currentPage = 1;
this.searchQuery = '';
},
searchQuery() {
this.currentPage = 1;
},
itemsPerPage() {
this.currentPage = 1;
}
},
computed: {
filteredByTabOtStatusList() {
if (this.activeTab === 'pending') {
return this.otStatusList.filter(row => row.canApprove || (row.IsOverallRejected && (row.currentUserStatus !== 'Approved' && row.currentUserStatus !== 'Rejected')));
} else if (this.activeTab === 'completed') {
return this.otStatusList.filter(row => row.currentUserStatus === 'Approved' || row.currentUserStatus === 'Rejected');
}
return [];
},
searchedOtStatusList() {
if (!this.searchQuery) {
return this.filteredByTabOtStatusList;
}
const query = this.searchQuery.toLowerCase();
return this.filteredByTabOtStatusList.filter(row => {
if (row.fullName && row.fullName.toLowerCase().includes(query)) return true;
if (row.currentUserStatus && row.currentUserStatus.toLowerCase().includes(query)) return true;
if (row.houStatus && row.houStatus.toLowerCase().includes(query)) return true;
if (row.hodStatus && row.hodStatus.toLowerCase().includes(query)) return true;
if (row.managerStatus && row.managerStatus.toLowerCase().includes(query)) return true;
if (row.hrStatus && row.hrStatus.toLowerCase().includes(query)) return true;
return false;
});
},
filteredAndSortedOtStatusList() {
let sortedList = [...this.searchedOtStatusList];
if (this.sortByColumn) {
sortedList.sort((a, b) => {
let valA = a[this.sortByColumn];
let valB = b[this.sortByColumn];
if (this.sortByColumn === 'currentUserStatus') {
const statusOrder = { 'Pending': 1, 'Approved': 2, 'Rejected': 3, 'N/A': 4, null: 5, undefined: 6, '': 7 };
const orderA = statusOrder[valA] || 99;
const orderB = statusOrder[valB] || 99;
if (this.sortDirection === 'asc') return orderA - orderB;
else return orderB - orderA;
}
else if (this.sortByColumn === 'submitDate') {
valA = valA ? new Date(valA) : new Date(0);
valB = valB ? new Date(valB) : new Date(0);
if (valA < valB) return this.sortDirection === 'asc' ? -1 : 1;
if (valA > valB) return this.sortDirection === 'asc' ? 1 : -1;
}
else if (typeof valA === 'string' && typeof valB === 'string') {
valA = valA.toLowerCase();
valB = valB.toLowerCase();
if (valA < valB) return this.sortDirection === 'asc' ? -1 : 1;
if (valA > valB) return this.sortDirection === 'asc' ? 1 : -1;
}
else {
if (valA < valB) return this.sortDirection === 'asc' ? -1 : 1;
if (valA > valB) return this.sortDirection === 'asc' ? 1 : -1;
}
return 0;
});
}
return sortedList;
},
totalPages() {
return Math.ceil(this.filteredAndSortedOtStatusList.length / this.itemsPerPage);
},
paginatedData() {
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = start + this.itemsPerPage;
return this.filteredAndSortedOtStatusList.slice(start, end);
},
pendingActionsCount() {
return this.otStatusList.filter(row => row.canApprove).length;
},
completedActionsCount() {
return this.otStatusList.filter(row => row.currentUserStatus === 'Approved' || row.currentUserStatus === 'Rejected').length;
}
},
methods: {
loadData() {
fetch(`/OvertimeAPI/GetPendingApproval?month=${this.selectedMonth}&year=${this.selectedYear}`)
.then(res => {
if (!res.ok) throw new Error("Network response was not OK");
return res.json();
})
.then(result => {
this.userRoles = result.roles;
this.otStatusList = result.data;
this.overallPendingMonths = result.overallPendingMonths || [];
this.currentPage = 1;
})
.catch(err => {
console.error("Error loading data:", err);
alert("Error loading data: " + err.message);
});
},
formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString();
},
getStatusBadgeClass(status) {
switch (status) {
case 'Approved': return 'badge badge-approved';
case 'Rejected': return 'badge badge-rejected';
case 'Pending': return 'badge badge-pending';
default: return 'badge bg-secondary';
}
},
updateStatus(statusId, decision) {
if (!statusId) {
console.error("Invalid statusId passed to updateStatus.");
alert("Error: Invalid request ID.");
return;
}
const actionText = decision === 'Approved' ? 'approve' : 'reject';
const confirmed = confirm(`Are you sure you want to ${actionText} this request?`);
if (!confirmed) return;
fetch('/OvertimeAPI/UpdateApprovalStatus', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ statusId: statusId, decision: decision })
})
.then(res => {
if (!res.ok) {
return res.json().then(err => { throw new Error(err.message || "Failed to update status"); });
}
return res.json();
})
.then(() => {
alert(`Request ${decision.toLowerCase()} successfully.`);
this.loadData();
})
.catch(err => {
console.error("Error updating status:", err);
alert("Error: " + err.message);
});
},
viewOtData(statusId) {
sessionStorage.setItem('approvalSelectedMonth', this.selectedMonth);
sessionStorage.setItem('approvalSelectedYear', this.selectedYear);
window.location.href = `/OTcalculate/ApprovalDashboard/OtReview?statusId=${statusId}`;
},
sortBy(column) {
if (this.sortByColumn === column) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortByColumn = column;
this.sortDirection = 'asc';
}
this.currentPage = 1;
}
},
mounted() {
this.loadData();
},
beforeUnmount() {
sessionStorage.setItem('approvalSelectedMonth', this.selectedMonth);
sessionStorage.setItem('approvalSelectedYear', this.selectedYear);
}
});
app.mount('#app');
</script>