PSTW_CentralizeSystem/Areas/OTcalculate/Views/Overtime/OtStatus.cshtml
2025-06-09 14:41:52 +08:00

773 lines
35 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">&laquo; 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 &raquo;</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">
<div class="modal-header">
<h5 class="modal-title" id="filePreviewModalLabel">File Preview</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<iframe :src="previewUrl" width="100%" height="650px" style="border: none;"></iframe>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Edit History Modal -->
<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">
<div class="modal-header">
<h5 class="modal-title" id="editHistoryModalLabel">Edit History for {{ selectedRecord ? formatMonthYear(selectedRecord.month, selectedRecord.year) : '' }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div v-if="parsedHistory.length > 0">
<div v-for="(entry, historyIndex) in parsedHistory" :key="historyIndex" class="history-entry">
<p><strong>Edited by:</strong> {{ entry.approvalRole }}</p>
<p><strong>Date:</strong> {{ formatDate(entry.date) }}</p>
<p><strong>Changes:</strong></p>
<ul v-if="entry.changes && entry.changes.length > 0">
<li v-for="(change, changeIndex) in entry.changes" :key="changeIndex">
<strong>{{ change.field }}:</strong> Changed from " <em>{{ change.before }}</em> " to " <em>{{ change.after }}</em> "
</li>
</ul>
<p v-else class="text-muted fst-italic">No specific changes detailed for this approval step.</p>
</div>
</div>
<div v-else>
<p class="text-center text-muted">No edit history available for this record.</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" 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: [],
// 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; // Month/Year, SubmitDate, Edit History
if (this.includeHou) count++;
if (this.includeHod) count++;
if (this.includeManager) count++;
if (this.includeHr) count++;
return count + 1; // File column
},
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' }
];
// Add combined status options if multiple approval levels exist
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];
// Apply filters
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;
});
}
}
// Apply sorting
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;
});
// Update pagination
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}`;
},
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';
},
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 (!before && !after) return [];
if (!before && after) {
for (const key in after) {
if (Object.prototype.hasOwnProperty.call(after, key)) {
let displayValue = after[key];
if (key === 'StationId') {
displayValue = this.getStationNameById(after[key]);
} else if (['OfficeBreak', 'AfterBreak'].includes(key)) {
displayValue = this.formatMinutesToHours(after[key]);
}
changes.push({ field: key, before: 'N/A (New Record)', after: displayValue });
}
}
return changes;
}
if (before && !after) {
return [];
}
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
allKeys.forEach(key => {
const beforeValue = before[key];
const afterValue = after[key];
let formattedBefore = beforeValue;
let formattedAfter = afterValue;
const timeFields = ['OfficeFrom', 'OfficeTo', 'AfterFrom', 'AfterTo'];
if (timeFields.includes(key)) {
if (typeof beforeValue === 'string' && beforeValue.match(/^\d{2}:\d{2}(:\d{2})?$/)) {
formattedBefore = beforeValue.substring(0, 5);
}
if (typeof afterValue === 'string' && afterValue.match(/^\d{2}:\d{2}(:\d{2})?$/)) {
formattedAfter = afterValue.substring(0, 5);
}
if (formattedBefore === "00:00" || formattedBefore === "00:00:00") formattedBefore = "";
if (formattedAfter === "00:00" || formattedAfter === "00:00:00") formattedAfter = "";
if (formattedBefore === null) formattedBefore = "";
if (formattedAfter === null) formattedAfter = "";
}
if (key === 'StationId') {
formattedBefore = this.getStationNameById(beforeValue);
formattedAfter = this.getStationNameById(afterValue);
}
else if (['OfficeBreak', 'AfterBreak'].includes(key)) {
formattedBefore = this.formatMinutesToHours(beforeValue);
formattedAfter = this.formatMinutesToHours(afterValue);
}
else if (key.includes('Date')) {
formattedBefore = beforeValue ? this.formatDate(beforeValue) : '';
formattedAfter = afterValue ? this.formatDate(afterValue) : '';
}
formattedBefore = (formattedBefore === null || formattedBefore === undefined) ? '' : formattedBefore;
formattedAfter = (formattedAfter === null || formattedAfter === undefined) ? '' : formattedAfter;
if (String(formattedBefore) !== String(formattedAfter)) {
if (!['StatusId', 'Otstatus', 'UserId', 'OvertimeId'].includes(key)) {
changes.push({
field: key,
before: formattedBefore === '' ? 'Empty' : formattedBefore,
after: formattedAfter === '' ? 'Empty' : 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: `Overtime ID: ${logEntry.DeletedRecord.OvertimeId} (Date: ${this.formatDate(logEntry.DeletedRecord.OtDate)}, Station: ${this.getStationNameById(logEntry.DeletedRecord.StationId)}, Desc: ${logEntry.DeletedRecord.OtDescription ? logEntry.DeletedRecord.OtDescription.substring(0, 50) + '...' : 'N/A'})`,
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(),
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 = [];
},
// Sorting methods
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; // Reset to first page when sorting changes
},
// Filter methods
resetFilters() {
this.filters = {
monthYear: '',
status: ''
};
this.pagination.currentPage = 1;
},
// Pagination methods
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>
}