Update OT

This commit is contained in:
Naz 2026-05-14 14:40:53 +08:00
parent ed30316685
commit 7ab4706524
6 changed files with 694 additions and 508 deletions

3
.gitignore vendored
View File

@ -361,6 +361,3 @@ MigrationBackup/
# Fody - auto-generated XML schema # Fody - auto-generated XML schema
FodyWeavers.xsd FodyWeavers.xsd
# Ignore local publish test builds
publish-test/

View File

@ -150,14 +150,32 @@
</ul> </ul>
<div class="table-layer"> <div class="table-layer">
<div class="mb-3"> <!-- Search + Bulk Actions -->
<input type="text" class="form-control form-control-sm" placeholder="Search by Staff Name or Status..." v-model="searchQuery" /> <div class="d-flex justify-content-between align-items-center mb-3">
<div style="width: 300px;">
<input type="text" class="form-control form-control-sm" placeholder="Search by Staff Name or Status..." v-model="searchQuery" />
</div>
<!-- Bulk Action Buttons -->
<div v-if="activeTab === 'pending' && selectedItems.length > 0" class="action-buttons">
@* <span class="me-3 fw-bold text-primary">{{ selectedItems.length }} Selected</span> *@
<button class="btn btn-success btn-sm" v-on:click="bulkUpdateStatus('Approved')">
</i> Approve Selected
</button>
<button class="btn btn-danger btn-sm" v-on:click="bulkUpdateStatus('Rejected')">
</i> Reject Selected
</button>
</div>
</div> </div>
<div class="table-container table-responsive"> <div class="table-container table-responsive">
<table class="table table-bordered table-sm table-striped"> <table class="table table-bordered table-sm table-striped">
<thead> <thead>
<tr> <tr>
<!-- Select All Checkbox -->
<th class="header text-center" style="width: 40px;">
<input type="checkbox" v-if="activeTab === 'pending'" v-model="selectAll" />
</th>
<th class="header sortable-header" v-on:click="sortBy('fullName')"> <th class="header sortable-header" v-on:click="sortBy('fullName')">
Staff Name Staff Name
<span class="sort-icon"> <span class="sort-icon">
@ -182,11 +200,19 @@
</template> </template>
</span> </span>
</th> </th>
<th class="header">Action</th> <th class="header text-center">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="row in paginatedData" :key="row.statusId"> <tr v-for="row in paginatedData" :key="row.statusId">
<!-- Individual Row Checkbox -->
<td class="text-center">
<input type="checkbox"
:value="row.statusId"
v-model="selectedItems"
v-if="activeTab === 'pending' && row.canApprove" />
</td>
<td>{{ row.fullName }}</td> <td>{{ row.fullName }}</td>
<td>{{ formatDate(row.submitDate) }}</td> <td>{{ formatDate(row.submitDate) }}</td>
<td> <td>
@ -194,13 +220,14 @@
<div v-else-if="row.role === 'HoD'">HoD: <span :class="getStatusBadgeClass(row.hodStatus)">{{ row.hodStatus }}</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 === '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-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"> <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> <span class="badge bg-danger">Rejected by a previous approver</span>
</div> </div>
</td> </td>
<td class="text-center">
<td>
<div class="d-flex align-items-center justify-content-center action-buttons"> <div class="d-flex align-items-center justify-content-center action-buttons">
<!-- Action Buttons -->
<template v-if="activeTab === 'pending'"> <template v-if="activeTab === 'pending'">
<template v-if="row.canApprove"> <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-success btn-sm" v-on:click="updateStatus(row.statusId, 'Approved')">Approve</button>
@ -213,15 +240,12 @@
<span class="badge bg-secondary">Awaiting previous approval</span> <span class="badge bg-secondary">Awaiting previous approval</span>
</template> </template>
</template> </template>
<template v-else-if="activeTab === 'completed'"></template>
<button class="btn btn-primary btn-sm" v-on:click="viewOtData(row.statusId)">View</button> <button class="btn btn-primary btn-sm" v-on:click="viewOtData(row.statusId)">View</button>
</div> </div>
</td> </td>
</tr> </tr>
<tr v-if="paginatedData.length === 0"> <tr v-if="paginatedData.length === 0">
<td colspan="4" class="text-center">No {{ activeTab === 'pending' ? 'pending' : 'completed' }} actions found for your current filters.</td> <td colspan="5" class="text-center">No {{ activeTab === 'pending' ? 'pending' : 'completed' }} actions found for your current filters.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -278,19 +302,32 @@
searchQuery: '', searchQuery: '',
currentPage: 1, currentPage: 1,
itemsPerPage: 10, itemsPerPage: 10,
overallPendingMonths: [] overallPendingMonths: [],
selectedItems: JSON.parse(sessionStorage.getItem('approvalSelectedItems')) || []
}; };
}, },
watch: { watch: {
activeTab() { activeTab() {
this.currentPage = 1; this.currentPage = 1;
this.searchQuery = ''; this.searchQuery = '';
this.selectedItems = [];
}, },
searchQuery() { searchQuery() {
this.currentPage = 1; this.currentPage = 1;
this.selectedItems = [];
}, },
itemsPerPage() { itemsPerPage() {
this.currentPage = 1; this.currentPage = 1;
this.selectedItems = [];
},
currentPage() {
this.selectedItems = [];
},
selectedItems: {
handler(newVal) {
sessionStorage.setItem('approvalSelectedItems', JSON.stringify(newVal));
},
deep: true
} }
}, },
computed: { computed: {
@ -365,6 +402,28 @@
}, },
completedActionsCount() { completedActionsCount() {
return this.otStatusList.filter(row => row.currentUserStatus === 'Approved' || row.currentUserStatus === 'Rejected').length; return this.otStatusList.filter(row => row.currentUserStatus === 'Approved' || row.currentUserStatus === 'Rejected').length;
},
selectAll: {
get() {
const approvableItems = this.paginatedData.filter(row => row.canApprove);
if (approvableItems.length === 0) return false;
return approvableItems.every(row => this.selectedItems.includes(row.statusId));
},
set(isChecked) {
const approvableItems = this.paginatedData.filter(row => row.canApprove);
if (isChecked) {
approvableItems.forEach(row => {
if (!this.selectedItems.includes(row.statusId)) {
this.selectedItems.push(row.statusId);
}
});
} else {
const approvableIds = approvableItems.map(row => row.statusId);
this.selectedItems = this.selectedItems.filter(id => !approvableIds.includes(id));
}
}
} }
}, },
methods: { methods: {
@ -443,6 +502,37 @@
this.sortDirection = 'asc'; this.sortDirection = 'asc';
} }
this.currentPage = 1; this.currentPage = 1;
},
bulkUpdateStatus(decision) {
if (this.selectedItems.length === 0) return;
const actionText = decision === 'Approved' ? 'approve' : 'reject';
const confirmed = confirm(`Are you sure you want to ${actionText} ${this.selectedItems.length} selected requests?`);
if (!confirmed) return;
const apiCalls = this.selectedItems.map(statusId => {
return fetch('/OvertimeAPI/UpdateApprovalStatus', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ statusId: statusId, decision: decision })
}).then(res => {
if (!res.ok) throw new Error("Failed to update status");
return res.json();
});
});
Promise.all(apiCalls)
.then(() => {
alert(`Successfully ${actionText}d ${this.selectedItems.length} requests.`);
this.selectedItems = [];
this.loadData();
})
.catch(err => {
console.error("Error updating bulk status:", err);
alert("An error occurred during bulk update. Some requests may not have saved properly. Refreshing data.");
this.loadData();
});
} }
}, },
mounted() { mounted() {

View File

@ -239,7 +239,8 @@
<button class="btn btn-dark btn-sm" v-on:click="saveAsPdf(false)"><i class="bi bi-file-pdf"></i> Save</button> <button class="btn btn-dark btn-sm" v-on:click="saveAsPdf(false)"><i class="bi bi-file-pdf"></i> Save</button>
<button class="btn btn-success btn-sm" v-on:click="exportToExcel(false)"><i class="bi bi-file-earmark-excel"></i> Excel</button> <button class="btn btn-success btn-sm" v-on:click="exportToExcel(false)"><i class="bi bi-file-earmark-excel"></i> Excel</button>
</div> </div>
<div class="mt-3 d-flex flex-wrap gap-2"> <!-- Added v-if="approverRole === 'HR'" to hide these buttons from other roles -->
<div class="mt-3 d-flex flex-wrap gap-2" v-if="approverRole === 'HR'">
<button class="btn btn-primary btn-sm" v-on:click="printPdf(true)"><i class="bi bi-printer"></i> Print No Salary</button> <button class="btn btn-primary btn-sm" v-on:click="printPdf(true)"><i class="bi bi-printer"></i> Print No Salary</button>
<button class="btn btn-dark btn-sm" v-on:click="saveAsPdf(true)"><i class="bi bi-file-pdf"></i> Save No Salary</button> <button class="btn btn-dark btn-sm" v-on:click="saveAsPdf(true)"><i class="bi bi-file-pdf"></i> Save No Salary</button>
<button class="btn btn-success btn-sm" v-on:click="exportToExcel(true)"><i class="bi bi-file-earmark-excel"></i> Excel No Salary</button> <button class="btn btn-success btn-sm" v-on:click="exportToExcel(true)"><i class="bi bi-file-earmark-excel"></i> Excel No Salary</button>

View File

@ -75,25 +75,32 @@
<div class="mb-3" v-if="showAirDropdown"> <div class="mb-3" v-if="showAirDropdown">
<label for="airStationDropdown">Air Station <span class="text-danger" v-if="requiresStation">*</span></label> <label for="airStationDropdown">Air Station <span class="text-danger" v-if="requiresStation">*</span></label>
<select id="airStationDropdown" class="form-control" style="width: 100%;" v-model="selectedAirStation">
<option value="">Select Air Station</option> <div class="border rounded bg-white">
<option v-for="station in airStationList" :key="station.stationId" :value="station.stationId"> <select id="airStationDropdown" class="form-control border-0" style="width: 100%;" v-model="selectedAirStation">
{{ station.stationName || 'Unnamed Station' }} <option value="">Select Air Station</option>
</option> <option v-for="station in airStationList" :key="station.stationId" :value="station.stationId">
</select> {{ station.stationName || 'Unnamed Station' }}
</option>
</select>
</div>
<small class="text-danger">*Only for PSTW AIR</small> <small class="text-danger">*Only for PSTW AIR</small>
</div> </div>
<div class="mb-3" v-if="showMarineDropdown"> <div class="mb-3" v-if="showMarineDropdown">
<label for="marineStationDropdown">Marine Station <span class="text-danger" v-if="requiresStation">*</span></label> <label for="marineStationDropdown">Marine Station <span class="text-danger" v-if="requiresStation">*</span></label>
<select id="marineStationDropdown" class="form-control" style="width: 100%;" v-model="selectedMarineStation">
<option value="">Select Marine Station</option> <div class="border rounded bg-white">
<option v-for="station in marineStationList" :key="station.stationId" :value="station.stationId"> <select id="marineStationDropdown" class="form-control border-0" style="width: 100%;" v-model="selectedMarineStation">
{{ station.stationName || 'Unnamed Station' }} <option value="">Select Marine Station</option>
</option> <option v-for="station in marineStationList" :key="station.stationId" :value="station.stationId">
</select> {{ station.stationName || 'Unnamed Station' }}
</option>
</select>
</div>
<small class="text-danger">*Only for PSTW MARINE</small> <small class="text-danger">*Only for PSTW MARINE</small>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="otDescription">Work Brief Description <span class="text-danger">*</span></label> <label for="otDescription">Work Brief Description <span class="text-danger">*</span></label>
<textarea id="otDescription" class="form-control" <textarea id="otDescription" class="form-control"
@ -139,7 +146,7 @@
<button class="btn btn-success ms-3" v-on:click="addOvertime" :disabled="!areUserSettingsComplete">Save</button> <button class="btn btn-success ms-3" v-on:click="addOvertime" :disabled="!areUserSettingsComplete">Save</button>
</div> </div>
<div v-if="!areUserSettingsComplete" class="alert alert-warning mt-3 text-center" role="alert"> <div v-if="!areUserSettingsComplete" class="alert alert-warning mt-3 text-center" role="alert">
Action Required: Your Flexi Hours, Approval Flow, Salary, or State settings have not been configured. Please contact the IT or HR department for assistance. You cannot save overtime until these are set. Action Required: Your Flexi Hours, Approval Flow, or State settings have not been configured. Please contact the IT or HR department for assistance. You cannot save overtime until these are set.
</div> </div>
</div> </div>
</div> </div>
@ -381,7 +388,7 @@
this.areUserSettingsComplete = data.isComplete; this.areUserSettingsComplete = data.isComplete;
if (!this.areUserSettingsComplete) { if (!this.areUserSettingsComplete) {
alert("Action Required: Your Flexi Hours, Approval Flow, Salary, or State settings have not been configured. Please contact the IT or HR department for assistance."); alert("Action Required: Your Flexi Hours, Approval Flow, or State settings have not been configured. Please contact the IT or HR department for assistance.");
} }
} catch (error) { } catch (error) {
console.error("Error checking user settings:", error); console.error("Error checking user settings:", error);

View File

@ -128,9 +128,9 @@
<div class="filter-row"> <div class="filter-row">
<div class="filter-group"> <div class="filter-group">
<label class="form-label">Month/Year</label> <label class="form-label">Month / Year</label>
<select class="form-select form-select-sm" v-model="filters.monthYear"> <select class="form-select form-select-sm" v-model="filters.monthYear">
<option value="">All Months/Years</option> <option value="">All Months / Years</option>
<option v-for="(option, index) in monthYearOptions" :key="index" :value="option.value"> <option v-for="(option, index) in monthYearOptions" :key="index" :value="option.value">
{{ option.text }} {{ option.text }}
</option> </option>
@ -254,49 +254,135 @@
<!-- File Preview Modal --> <!-- File Preview Modal -->
<div class="modal fade" id="filePreviewModal" tabindex="-1" aria-labelledby="filePreviewModalLabel" aria-hidden="true"> <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-dialog modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header"> <div class="modal-content border-0 shadow-lg rounded-3">
<h5 class="modal-title" id="filePreviewModalLabel">File Preview</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <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>
<div class="modal-body">
<iframe :src="previewUrl" width="100%" height="650px" style="border: none;"></iframe> <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>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <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>
</div> </div>
</div> </div>
<!-- Edit History Modal --> <!-- Edit History -->
<div class="modal fade" id="editHistoryModal" tabindex="-1" aria-labelledby="editHistoryModalLabel" aria-hidden="true"> <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-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content border-0 shadow">
<div class="modal-header">
<h5 class="modal-title" id="editHistoryModalLabel">Edit History for {{ selectedRecord ? formatMonthYear(selectedRecord.month, selectedRecord.year) : '' }}</h5> <div class="modal-header border-bottom-0 pb-0">
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <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>
<div class="modal-body">
<div v-if="parsedHistory.length > 0"> <div class="modal-body p-4">
<div v-for="(entry, historyIndex) in parsedHistory" :key="historyIndex" class="history-entry"> <p class="text-muted mb-4 ms-2">Record: <strong>{{ selectedRecord ? formatMonthYear(selectedRecord.month, selectedRecord.year) : '' }}</strong></p>
<p><strong>Edited by:</strong> {{ entry.approvalRole }}</p>
<p><strong>Date:</strong> {{ formatDate(entry.date) }}</p> <div v-if="parsedHistory.length > 0" class="border-start border-2 border-secondary border-opacity-25 ms-3 ps-4 position-relative">
<p><strong>Changes:</strong></p>
<ul v-if="entry.changes && entry.changes.length > 0"> <!-- Timeline Node -->
<li v-for="(change, changeIndex) in entry.changes" :key="changeIndex"> <div v-for="(entry, historyIndex) in parsedHistory" :key="historyIndex" class="position-relative mb-5">
<strong>{{ change.field }}:</strong> Changed from " <em>{{ change.before }}</em> " to " <em>{{ change.after }}</em> "
</li> <!-- Node Dot -->
</ul> <span class="position-absolute translate-middle p-2 rounded-circle border border-2 border-white"
<p v-else class="text-muted fst-italic">No specific changes detailed for this approval step.</p> :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">
{{ entry.changes.some(c => c.field === 'Record Deletion') ? 'deleted the record' : 'updated the record' }}
</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> </div>
<div v-else>
<p class="text-center text-muted">No edit history available for this record.</p> <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> </div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <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> </div>
@ -319,6 +405,7 @@
historyModalInstance: null, historyModalInstance: null,
filePreviewModalInstance: null, filePreviewModalInstance: null,
stations: [], stations: [],
zoomLevel: 1.0,
// Filtering data // Filtering data
filters: { filters: {
@ -545,7 +632,20 @@
if (!month || !year) return '-'; if (!month || !year) return '-';
return `${month.toString().padStart(2, '0')}/${year}`; 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) { getStatusBadgeClass(status) {
if (!status) return ''; if (!status) return '';
const statusLower = status.toLowerCase(); const statusLower = status.toLowerCase();
@ -554,6 +654,24 @@
return 'badge badge-status badge-pending'; 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) { previewFile(path) {
this.previewUrl = '/' + path.replace(/^\/+/, ''); this.previewUrl = '/' + path.replace(/^\/+/, '');
if (this.filePreviewModalInstance) { if (this.filePreviewModalInstance) {
@ -584,77 +702,33 @@
getChanges(before, after) { getChanges(before, after) {
const changes = []; const changes = [];
if (!before && !after) return []; // Safety check: if there is no before/after data, return empty
if (!before && after) { if (!before || !after) return changes;
for (const key in after) {
if (Object.prototype.hasOwnProperty.call(after, key)) { // Loop through ONLY the keys provided by the clean JSON
let displayValue = after[key]; for (const key in after) {
if (key === 'StationId') { if (Object.prototype.hasOwnProperty.call(after, key)) {
displayValue = this.getStationNameById(after[key]); let formattedBefore = before[key];
} else if (['OfficeBreak', 'AfterBreak'].includes(key)) { let formattedAfter = after[key];
displayValue = this.formatMinutesToHours(after[key]);
} // Resolve Station IDs to Station Names
changes.push({ field: key, before: 'N/A (New Record)', after: displayValue }); 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";
} }
changes.push({
field: key,
before: formattedBefore,
after: formattedAfter
});
} }
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; return changes;
}, },
@ -687,7 +761,7 @@
if (logEntry.DeletedRecord) { if (logEntry.DeletedRecord) {
changesDetail.push({ changesDetail.push({
field: 'Record Deletion', 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'})`, before: this.formatDate(logEntry.DeletedRecord.OtDate),
after: 'Record Deleted' after: 'Record Deleted'
}); });
} else { } else {

View File

@ -881,317 +881,315 @@ namespace PSTW_CentralSystem.Controllers.API
#endregion #endregion
#region Ot Register #region Ot Register
[HttpGet("GetStationsByDepartment")] [HttpGet("GetStationsByDepartment")]
public async Task<IActionResult> GetStationsByDepartment([FromQuery] int? departmentId) public async Task<IActionResult> GetStationsByDepartment([FromQuery] int? departmentId)
{
if (!departmentId.HasValue)
{ {
if (!departmentId.HasValue) _logger.LogWarning("GetStationsByDepartment called without a departmentId.");
return Ok(new List<object>());
}
var stations = await _centralDbContext.Stations
.Where(s => s.DepartmentId == departmentId.Value)
.Select(s => new
{ {
_logger.LogWarning("GetStationsByDepartment called without a departmentId."); s.StationId,
return Ok(new List<object>()); StationName = s.StationName ?? "Unnamed Station"
})
.ToListAsync();
return Ok(stations);
}
[HttpPost("AddOvertime")]
public async Task<IActionResult> AddOvertimeAsync([FromBody] OvertimeRequestDto request)
{
_logger.LogInformation("AddOvertimeAsync called.");
_logger.LogInformation("Received request: {@Request}", request);
if (request == null)
{
_logger.LogError("Request is null.");
return BadRequest("Invalid data.");
}
try
{
if (request.UserId == 0)
{
_logger.LogWarning("No user ID provided.");
return BadRequest("User ID is required.");
} }
var stations = await _centralDbContext.Stations var user = await _userManager.FindByIdAsync(request.UserId.ToString());
.Where(s => s.DepartmentId == departmentId.Value) if (user == null)
.Select(s => new {
_logger.LogError("User with ID {UserId} not found for overtime submission.", request.UserId);
return Unauthorized("User not found.");
}
// Prevent adding records to a month that is already submitted
var targetMonth = request.OtDate.Month;
var targetYear = request.OtDate.Year;
var isMonthAlreadySubmitted = await _centralDbContext.Otstatus
.AnyAsync(s => s.UserId == request.UserId && s.Month == targetMonth && s.Year == targetYear);
if (isMonthAlreadySubmitted)
{
return BadRequest($"Overtime for {request.OtDate:MMMM yyyy} has already been submitted. You cannot add new records to a locked month.");
}
var userRoles = await _userManager.GetRolesAsync(user);
var isSuperAdmin = userRoles.Contains("SuperAdmin");
var isSystemAdmin = userRoles.Contains("SystemAdmin");
var userWithDepartment = await _centralDbContext.Users
.Include(u => u.Department)
.FirstOrDefaultAsync(u => u.Id == request.UserId);
int? userDepartmentId = userWithDepartment?.Department?.DepartmentId;
bool stationRequired = false;
if (userDepartmentId == 2 || userDepartmentId == 3)
{
stationRequired = true;
}
else if (isSuperAdmin || isSystemAdmin)
{
stationRequired = true;
}
if (stationRequired && (!request.StationId.HasValue || request.StationId.Value <= 0))
{
return BadRequest("A station must be selected.");
}
TimeSpan? officeFrom = string.IsNullOrEmpty(request.OfficeFrom) ? (TimeSpan?)null : TimeSpan.Parse(request.OfficeFrom);
TimeSpan? officeTo = string.IsNullOrEmpty(request.OfficeTo) ? (TimeSpan?)null : TimeSpan.Parse(request.OfficeTo);
TimeSpan? afterFrom = string.IsNullOrEmpty(request.AfterFrom) ? (TimeSpan?)null : TimeSpan.Parse(request.AfterFrom);
TimeSpan? afterTo = string.IsNullOrEmpty(request.AfterTo) ? (TimeSpan?)null : TimeSpan.Parse(request.AfterTo);
if ((officeFrom != null && officeTo == null) || (officeFrom == null && officeTo != null))
{
return BadRequest("Both Office From and To times must be provided if one is entered.");
}
if ((afterFrom != null && afterTo == null) || (afterFrom == null && afterTo != null))
{
return BadRequest("Both After Office From and To times must be provided if one is entered.");
}
TimeSpan minAllowedFromMidnightTo = new TimeSpan(16, 30, 0);
TimeSpan maxAllowedFromMidnightTo = new TimeSpan(23, 30, 0);
if (officeFrom.HasValue && officeTo.HasValue)
{
if (officeTo == TimeSpan.Zero)
{ {
s.StationId, if (officeFrom == TimeSpan.Zero)
StationName = s.StationName ?? "Unnamed Station" {
}) return BadRequest("Invalid Office Hour Time: 'From' and 'To' cannot both be 00:00 (midnight).");
}
if (officeFrom.Value < minAllowedFromMidnightTo || officeFrom.Value > maxAllowedFromMidnightTo)
{
return BadRequest("Invalid Office Hour Time: If 'To' is 12:00 am (00:00), 'From' must start between end of your flexi hour to 11:30 pm on the same day.");
}
}
else if (officeTo <= officeFrom)
{
return BadRequest("Invalid Office Hour Time: 'To' time must be later than 'From' time (same day only).");
}
}
if (afterFrom.HasValue && afterTo.HasValue)
{
if (afterTo == TimeSpan.Zero)
{
if (afterFrom == TimeSpan.Zero)
{
return BadRequest("Invalid After Office Hour Time: 'From' and 'To' cannot both be 00:00 (midnight).");
}
if (afterFrom.Value < minAllowedFromMidnightTo || afterFrom.Value > maxAllowedFromMidnightTo)
{
return BadRequest("Invalid After Office Hour Time: If 'To' is 12:00 am (00:00), 'From' must start between end of your flexi hour to 11:30 pm on the same day.");
}
}
else if (afterTo <= afterFrom)
{
return BadRequest("Invalid After Office Hour Time: 'To' time must be later than 'From' time (same day only).");
}
}
if ((officeFrom == null && officeTo == null) && (afterFrom == null && afterTo == null))
{
return BadRequest("Please enter either Office Hours or After Office Hours.");
}
// Convert the request date to a DateOnly for comparison
DateOnly requestDate = DateOnly.FromDateTime(request.OtDate);
// Fetch existing overtime records for the same user and date
var existingOvertimeRecords = await _centralDbContext.Otregisters
.Where(o => o.UserId == request.UserId && DateOnly.FromDateTime(o.OtDate) == requestDate)
.ToListAsync(); .ToListAsync();
return Ok(stations); // Check for overlaps with existing records
foreach (var existingRecord in existingOvertimeRecords)
{
// Convert existing record times to actual TimeSpan objects
var existingOfficeFrom = existingRecord.OfficeFrom;
var existingOfficeTo = existingRecord.OfficeTo;
var existingAfterFrom = existingRecord.AfterFrom;
var existingAfterTo = existingRecord.AfterTo;
// Function to check for overlap between two time ranges
Func<TimeSpan?, TimeSpan?, TimeSpan?, TimeSpan?, bool> CheckOverlap =
(newStart, newEnd, existingStart, existingEnd) =>
{
if (!newStart.HasValue || !newEnd.HasValue || !existingStart.HasValue || !existingEnd.HasValue)
{
return false; // No overlap if either range is incomplete
}
// Handle midnight (00:00) as end of day (24:00) for comparison purposes
TimeSpan adjustedNewEnd = (newEnd == TimeSpan.Zero && newStart != TimeSpan.Zero) ? TimeSpan.FromHours(24) : newEnd.Value;
TimeSpan adjustedExistingEnd = (existingEnd == TimeSpan.Zero && existingStart != TimeSpan.Zero) ? TimeSpan.FromHours(24) : existingEnd.Value;
// Check for overlap:
// New range starts before existing range ends AND
// New range ends after existing range starts
return (newStart.Value < adjustedExistingEnd && adjustedNewEnd > existingStart.Value);
};
// Check for overlap between new Office Hours and existing Office Hours
if (CheckOverlap(officeFrom, officeTo, existingOfficeFrom, existingOfficeTo))
{
return BadRequest("Your Office Hours entry overlaps with another record on this date. Kindly adjust your time.");
}
// Check for overlap between new After Office Hours and existing After Office Hours
if (CheckOverlap(afterFrom, afterTo, existingAfterFrom, existingAfterTo))
{
return BadRequest("Your After Office Hours entry overlaps with another record on this date. Kindly adjust your time.");
}
// Check for overlap between new Office Hours and existing After Office Hours
if (CheckOverlap(officeFrom, officeTo, existingAfterFrom, existingAfterTo))
{
return BadRequest("Your Office Hours entry overlaps with another record on this date. Kindly adjust your time.");
}
// Check for overlap between new After Office Hours and existing Office Hours
if (CheckOverlap(afterFrom, afterTo, existingOfficeFrom, existingOfficeTo))
{
return BadRequest("Your After Office Hours entry overlaps with another record on this date. Kindly adjust your time.");
}
}
var newRecord = new OtRegisterModel
{
OtDate = request.OtDate,
OfficeFrom = officeFrom,
OfficeTo = officeTo,
OfficeBreak = request.OfficeBreak,
AfterFrom = afterFrom,
AfterTo = afterTo,
AfterBreak = request.AfterBreak,
StationId = request.StationId,
OtDescription = request.OtDescription,
OtDays = request.OtDays,
UserId = request.UserId,
StatusId = request.StatusId
};
_centralDbContext.Otregisters.Add(newRecord);
await _centralDbContext.SaveChangesAsync();
return Ok(new { message = "Overtime registered successfully." });
} }
catch (Exception ex)
[HttpPost("AddOvertime")]
public async Task<IActionResult> AddOvertimeAsync([FromBody] OvertimeRequestDto request)
{ {
_logger.LogInformation("AddOvertimeAsync called."); _logger.LogError(ex, "Error registering overtime for user {UserId}.", request.UserId);
_logger.LogInformation("Received request: {@Request}", request); return StatusCode(500, $"An error occurred while saving overtime: {ex.InnerException?.Message ?? ex.Message}");
if (request == null)
{
_logger.LogError("Request is null.");
return BadRequest("Invalid data.");
}
try
{
if (request.UserId == 0)
{
_logger.LogWarning("No user ID provided.");
return BadRequest("User ID is required.");
}
var user = await _userManager.FindByIdAsync(request.UserId.ToString());
if (user == null)
{
_logger.LogError("User with ID {UserId} not found for overtime submission.", request.UserId);
return Unauthorized("User not found.");
}
var userRoles = await _userManager.GetRolesAsync(user);
var isSuperAdmin = userRoles.Contains("SuperAdmin");
var isSystemAdmin = userRoles.Contains("SystemAdmin");
var userWithDepartment = await _centralDbContext.Users
.Include(u => u.Department)
.FirstOrDefaultAsync(u => u.Id == request.UserId);
int? userDepartmentId = userWithDepartment?.Department?.DepartmentId;
bool stationRequired = false;
if (userDepartmentId == 2 || userDepartmentId == 3)
{
stationRequired = true;
}
else if (isSuperAdmin || isSystemAdmin)
{
stationRequired = true;
}
if (stationRequired && (!request.StationId.HasValue || request.StationId.Value <= 0))
{
return BadRequest("A station must be selected.");
}
TimeSpan? officeFrom = string.IsNullOrEmpty(request.OfficeFrom) ? (TimeSpan?)null : TimeSpan.Parse(request.OfficeFrom);
TimeSpan? officeTo = string.IsNullOrEmpty(request.OfficeTo) ? (TimeSpan?)null : TimeSpan.Parse(request.OfficeTo);
TimeSpan? afterFrom = string.IsNullOrEmpty(request.AfterFrom) ? (TimeSpan?)null : TimeSpan.Parse(request.AfterFrom);
TimeSpan? afterTo = string.IsNullOrEmpty(request.AfterTo) ? (TimeSpan?)null : TimeSpan.Parse(request.AfterTo);
if ((officeFrom != null && officeTo == null) || (officeFrom == null && officeTo != null))
{
return BadRequest("Both Office From and To times must be provided if one is entered.");
}
if ((afterFrom != null && afterTo == null) || (afterFrom == null && afterTo != null))
{
return BadRequest("Both After Office From and To times must be provided if one is entered.");
}
TimeSpan minAllowedFromMidnightTo = new TimeSpan(16, 30, 0);
TimeSpan maxAllowedFromMidnightTo = new TimeSpan(23, 30, 0);
if (officeFrom.HasValue && officeTo.HasValue)
{
if (officeTo == TimeSpan.Zero)
{
if (officeFrom == TimeSpan.Zero)
{
return BadRequest("Invalid Office Hour Time: 'From' and 'To' cannot both be 00:00 (midnight).");
}
if (officeFrom.Value < minAllowedFromMidnightTo || officeFrom.Value > maxAllowedFromMidnightTo)
{
return BadRequest("Invalid Office Hour Time: If 'To' is 12:00 am (00:00), 'From' must start between end of your flexi hour to 11:30 pm on the same day.");
}
}
else if (officeTo <= officeFrom)
{
return BadRequest("Invalid Office Hour Time: 'To' time must be later than 'From' time (same day only).");
}
}
if (afterFrom.HasValue && afterTo.HasValue)
{
if (afterTo == TimeSpan.Zero)
{
if (afterFrom == TimeSpan.Zero)
{
return BadRequest("Invalid After Office Hour Time: 'From' and 'To' cannot both be 00:00 (midnight).");
}
if (afterFrom.Value < minAllowedFromMidnightTo || afterFrom.Value > maxAllowedFromMidnightTo)
{
return BadRequest("Invalid After Office Hour Time: If 'To' is 12:00 am (00:00), 'From' must start between end of your flexi hour to 11:30 pm on the same day.");
}
}
else if (afterTo <= afterFrom)
{
return BadRequest("Invalid After Office Hour Time: 'To' time must be later than 'From' time (same day only).");
}
}
if ((officeFrom == null && officeTo == null) && (afterFrom == null && afterTo == null))
{
return BadRequest("Please enter either Office Hours or After Office Hours.");
}
// Convert the request date to a DateOnly for comparison
DateOnly requestDate = DateOnly.FromDateTime(request.OtDate);
// Fetch existing overtime records for the same user and date
var existingOvertimeRecords = await _centralDbContext.Otregisters
.Where(o => o.UserId == request.UserId && DateOnly.FromDateTime(o.OtDate) == requestDate)
.ToListAsync();
// Check for overlaps with existing records
foreach (var existingRecord in existingOvertimeRecords)
{
// Convert existing record times to actual TimeSpan objects
var existingOfficeFrom = existingRecord.OfficeFrom;
var existingOfficeTo = existingRecord.OfficeTo;
var existingAfterFrom = existingRecord.AfterFrom;
var existingAfterTo = existingRecord.AfterTo;
// Function to check for overlap between two time ranges
Func<TimeSpan?, TimeSpan?, TimeSpan?, TimeSpan?, bool> CheckOverlap =
(newStart, newEnd, existingStart, existingEnd) =>
{
if (!newStart.HasValue || !newEnd.HasValue || !existingStart.HasValue || !existingEnd.HasValue)
{
return false; // No overlap if either range is incomplete
}
// Handle midnight (00:00) as end of day (24:00) for comparison purposes
TimeSpan adjustedNewEnd = (newEnd == TimeSpan.Zero && newStart != TimeSpan.Zero) ? TimeSpan.FromHours(24) : newEnd.Value;
TimeSpan adjustedExistingEnd = (existingEnd == TimeSpan.Zero && existingStart != TimeSpan.Zero) ? TimeSpan.FromHours(24) : existingEnd.Value;
// Check for overlap:
// New range starts before existing range ends AND
// New range ends after existing range starts
return (newStart.Value < adjustedExistingEnd && adjustedNewEnd > existingStart.Value);
};
// Check for overlap between new Office Hours and existing Office Hours
if (CheckOverlap(officeFrom, officeTo, existingOfficeFrom, existingOfficeTo))
{
return BadRequest("Your Office Hours entry overlaps with another record on this date. Kindly adjust your time.");
}
// Check for overlap between new After Office Hours and existing After Office Hours
if (CheckOverlap(afterFrom, afterTo, existingAfterFrom, existingAfterTo))
{
return BadRequest("Your Office Hours entry overlaps with another record on this date. Kindly adjust your time.");
}
// Check for overlap between new Office Hours and existing After Office Hours
if (CheckOverlap(officeFrom, officeTo, existingAfterFrom, existingAfterTo))
{
return BadRequest("Your Office Hours entry overlaps with another record on this date. Kindly adjust your time.");
}
// Check for overlap between new After Office Hours and existing Office Hours
if (CheckOverlap(afterFrom, afterTo, existingOfficeFrom, existingOfficeTo))
{
return BadRequest("Your Office Hours entry overlaps with another record on this date. Kindly adjust your time.");
}
}
var newRecord = new OtRegisterModel
{
OtDate = request.OtDate,
OfficeFrom = officeFrom,
OfficeTo = officeTo,
OfficeBreak = request.OfficeBreak,
AfterFrom = afterFrom,
AfterTo = afterTo,
AfterBreak = request.AfterBreak,
StationId = request.StationId,
OtDescription = request.OtDescription,
OtDays = request.OtDays,
UserId = request.UserId,
StatusId = request.StatusId
};
_centralDbContext.Otregisters.Add(newRecord);
await _centralDbContext.SaveChangesAsync();
return Ok(new { message = "Overtime registered successfully." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error registering overtime for user {UserId}.", request.UserId);
return StatusCode(500, $"An error occurred while saving overtime: {ex.InnerException?.Message ?? ex.Message}");
}
} }
}
[HttpGet("GetUserStateAndHolidays/{userId}")] [HttpGet("GetUserStateAndHolidays/{userId}")]
public async Task<IActionResult> GetUserStateAndHolidaysAsync(int userId) public async Task<IActionResult> GetUserStateAndHolidaysAsync(int userId)
{
try
{ {
try var hrSettings = await _centralDbContext.Hrusersetting
.Include(h => h.State)
.ThenInclude(s => s.Weekends)
.Include(h => h.FlexiHour)
.Where(h => h.UserId == userId)
.FirstOrDefaultAsync();
if (hrSettings?.State == null)
{ {
var hrSettings = await _centralDbContext.Hrusersetting return Ok(new { state = (object)null, publicHolidays = new List<object>() });
.Include(h => h.State)
.ThenInclude(s => s.Weekends)
.Include(h => h.FlexiHour)
.Where(h => h.UserId == userId)
.FirstOrDefaultAsync();
if (hrSettings?.State == null)
{
return Ok(new { state = (object)null, publicHolidays = new List<object>() });
}
var publicHolidays = await _centralDbContext.Holidays
.Where(ph => ph.StateId == hrSettings.StateId && ph.HolidayDate.Year == DateTime.Now.Year)
.Select(ph => new { Date = ph.HolidayDate.ToString("yyyy-MM-dd") })
.ToListAsync();
return Ok(new
{
state = new
{
stateId = hrSettings.StateId,
stateName = hrSettings.State?.StateName,
weekendDay = hrSettings.State?.Weekends?.Day,
weekendId = hrSettings.State?.WeekendId,
flexiHour = hrSettings.FlexiHour?.FlexiHour
},
publicHolidays
});
} }
catch (Exception ex)
var publicHolidays = await _centralDbContext.Holidays
.Where(ph => ph.StateId == hrSettings.StateId && ph.HolidayDate.Year == DateTime.Now.Year)
.Select(ph => new { Date = ph.HolidayDate.ToString("yyyy-MM-dd") })
.ToListAsync();
return Ok(new
{ {
_logger.LogError(ex, "Error fetching user state and public holidays."); state = new
return StatusCode(500, "An error occurred while fetching user state and public holidays."); {
} stateId = hrSettings.StateId,
stateName = hrSettings.State?.StateName,
weekendDay = hrSettings.State?.Weekends?.Day,
weekendId = hrSettings.State?.WeekendId,
flexiHour = hrSettings.FlexiHour?.FlexiHour
},
publicHolidays
});
} }
catch (Exception ex)
[HttpGet("CheckUserSettings/{userId}")]
public async Task<IActionResult> CheckUserSettings(int userId)
{ {
try _logger.LogError(ex, "Error fetching user state and public holidays.");
{ return StatusCode(500, "An error occurred while fetching user state and public holidays.");
var hrSettings = await _centralDbContext.Hrusersetting
.Where(h => h.UserId == userId)
.FirstOrDefaultAsync();
if (hrSettings == null)
{
return Ok(new { isComplete = false });
}
if (hrSettings.FlexiHourId == null || hrSettings.StateId == null || hrSettings.ApprovalFlowId == null)
{
return Ok(new { isComplete = false });
}
var rateSetting = await _centralDbContext.Rates
.Where(r => r.UserId == userId)
.FirstOrDefaultAsync();
if (rateSetting == null)
{
return Ok(new { isComplete = false });
}
else
{
if (rateSetting.RateValue <= 0.00m)
{
return Ok(new { isComplete = false });
}
}
return Ok(new { isComplete = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking user settings for user {UserId}", userId);
return StatusCode(500, $"An error occurred while checking user settings: {ex.Message}");
}
} }
}
[HttpGet("CheckUserSettings/{userId}")]
public async Task<IActionResult> CheckUserSettings(int userId)
{
try
{
var hrSettings = await _centralDbContext.Hrusersetting
.Where(h => h.UserId == userId)
.FirstOrDefaultAsync();
if (hrSettings == null)
{
return Ok(new { isComplete = false });
}
// NOT check Salary/Rate
if (hrSettings.FlexiHourId == null || hrSettings.StateId == null || hrSettings.ApprovalFlowId == null)
{
return Ok(new { isComplete = false });
}
return Ok(new { isComplete = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error checking user settings for user {UserId}", userId);
return StatusCode(500, $"An error occurred while checking user settings: {ex.Message}");
}
}
#endregion #endregion
#region Ot Records #region Ot Records
@ -1565,17 +1563,12 @@ namespace PSTW_CentralSystem.Controllers.API
private bool HasOverlappingRecords(OtRegisterUpdateDto model, int userId) private bool HasOverlappingRecords(OtRegisterUpdateDto model, int userId)
{ {
// Retrieve all other overtime records for the same user and date, // Get other OT records for this user on the same dat
// excluding the current record being updated.
var existingRecords = _centralDbContext.Otregisters var existingRecords = _centralDbContext.Otregisters
.Where(o => o.UserId == userId && o.OtDate.Date == model.OtDate.Date && o.OvertimeId != model.OvertimeId) .Where(o => o.UserId == userId && o.OtDate.Date == model.OtDate.Date && o.OvertimeId != model.OvertimeId)
.ToList(); .ToList();
// The logic to check for overlap between two time ranges A and B is: // Overlap formula: Start A < End B && Start B < End A
// A_start < B_end AND B_start < A_end
// This handles all scenarios, including one range being entirely inside another.
// Check for overlap with the Office Hours block
if (!string.IsNullOrEmpty(model.OfficeFrom) && !string.IsNullOrEmpty(model.OfficeTo)) if (!string.IsNullOrEmpty(model.OfficeFrom) && !string.IsNullOrEmpty(model.OfficeTo))
{ {
var newOfficeStart = TimeSpan.Parse(model.OfficeFrom); var newOfficeStart = TimeSpan.Parse(model.OfficeFrom);
@ -2139,121 +2132,152 @@ namespace PSTW_CentralSystem.Controllers.API
return Ok(holidays); return Ok(holidays);
} }
public class OtUpdateLog
{
public string ApproverRole { get; set; }
public int ApproverUserId { get; set; }
public DateTime UpdateTimestamp { get; set; }
public string ChangeType { get; set; }
// Save the exact fields that changed
public Dictionary<string, string> BeforeEdit { get; set; }
public Dictionary<string, string> AfterEdit { get; set; }
public object DeletedRecord { get; set; }
}
[HttpPost("UpdateOtRecordByApprover")] [HttpPost("UpdateOtRecordByApprover")]
public IActionResult UpdateOtRecordByApprover([FromBody] OtRegisterEditDto updatedRecordDto) public IActionResult UpdateOtRecordByApprover([FromBody] OtRegisterEditDto updatedRecordDto)
{ {
if (updatedRecordDto == null) if (updatedRecordDto == null) return BadRequest("Invalid record data.");
{
return BadRequest("Invalid record data.");
}
var existingRecord = _centralDbContext.Otregisters.FirstOrDefault(o => o.OvertimeId == updatedRecordDto.OvertimeId); var existingRecord = _centralDbContext.Otregisters.FirstOrDefault(o => o.OvertimeId == updatedRecordDto.OvertimeId);
if (existingRecord == null) if (existingRecord == null) return NotFound(new { message = "Overtime record not found." });
{
return NotFound(new { message = "Overtime record not found." });
}
// **NEW LOGIC: Check for overlapping entries**
if (IsOverlapping(updatedRecordDto, existingRecord.UserId, updatedRecordDto.OvertimeId)) if (IsOverlapping(updatedRecordDto, existingRecord.UserId, updatedRecordDto.OvertimeId))
return BadRequest(new { message = "The new overtime time range overlaps with an existing entry." });
TimeSpan? newOfficeFrom = ParseTimeStringToTimeSpan(updatedRecordDto.OfficeFrom);
TimeSpan? newOfficeTo = ParseTimeStringToTimeSpan(updatedRecordDto.OfficeTo);
TimeSpan? newAfterFrom = ParseTimeStringToTimeSpan(updatedRecordDto.AfterFrom);
TimeSpan? newAfterTo = ParseTimeStringToTimeSpan(updatedRecordDto.AfterTo);
var beforeChanges = new Dictionary<string, string>();
var afterChanges = new Dictionary<string, string>();
if (existingRecord.OtDate.Date != updatedRecordDto.OtDate.Date)
{ {
return BadRequest(new { message = "The new overtime time range overlaps with an existing entry for the same date." }); beforeChanges["OtDate"] = existingRecord.OtDate.ToString("yyyy-MM-dd");
afterChanges["OtDate"] = updatedRecordDto.OtDate.ToString("yyyy-MM-dd");
}
if (existingRecord.OfficeFrom != newOfficeFrom)
{
beforeChanges["OfficeFrom"] = existingRecord.OfficeFrom?.ToString(@"hh\:mm") ?? "Empty";
afterChanges["OfficeFrom"] = newOfficeFrom?.ToString(@"hh\:mm") ?? "Empty";
}
if (existingRecord.OfficeTo != newOfficeTo)
{
beforeChanges["OfficeTo"] = existingRecord.OfficeTo?.ToString(@"hh\:mm") ?? "Empty";
afterChanges["OfficeTo"] = newOfficeTo?.ToString(@"hh\:mm") ?? "Empty";
}
if ((existingRecord.OfficeBreak ?? 0) != (updatedRecordDto.OfficeBreak ?? 0))
{
beforeChanges["OfficeBreak"] = (existingRecord.OfficeBreak ?? 0).ToString();
afterChanges["OfficeBreak"] = (updatedRecordDto.OfficeBreak ?? 0).ToString();
}
if (existingRecord.AfterFrom != newAfterFrom)
{
beforeChanges["AfterFrom"] = existingRecord.AfterFrom?.ToString(@"hh\:mm") ?? "Empty";
afterChanges["AfterFrom"] = newAfterFrom?.ToString(@"hh\:mm") ?? "Empty";
}
if (existingRecord.AfterTo != newAfterTo)
{
beforeChanges["AfterTo"] = existingRecord.AfterTo?.ToString(@"hh\:mm") ?? "Empty";
afterChanges["AfterTo"] = newAfterTo?.ToString(@"hh\:mm") ?? "Empty";
}
if ((existingRecord.AfterBreak ?? 0) != (updatedRecordDto.AfterBreak ?? 0))
{
beforeChanges["AfterBreak"] = (existingRecord.AfterBreak ?? 0).ToString();
afterChanges["AfterBreak"] = (updatedRecordDto.AfterBreak ?? 0).ToString();
}
if (existingRecord.StationId != updatedRecordDto.StationId)
{
beforeChanges["StationId"] = existingRecord.StationId?.ToString() ?? "Empty";
afterChanges["StationId"] = updatedRecordDto.StationId?.ToString() ?? "Empty";
}
if (existingRecord.OtDescription != updatedRecordDto.OtDescription)
{
beforeChanges["OtDescription"] = existingRecord.OtDescription ?? "Empty";
afterChanges["OtDescription"] = updatedRecordDto.OtDescription ?? "Empty";
}
// Accidentally clicked save without changing anything
if (beforeChanges.Count == 0)
{
return Ok(new { message = "No changes detected. Record saved." });
} }
// ... (rest of your existing logic for finding otStatus, permissions, and logging)
// The previous code for validation will be moved to a helper method or retained if it's still needed
var otStatus = _centralDbContext.Otstatus.FirstOrDefault(s => s.StatusId == updatedRecordDto.StatusId); var otStatus = _centralDbContext.Otstatus.FirstOrDefault(s => s.StatusId == updatedRecordDto.StatusId);
if (otStatus == null) if (otStatus == null) return NotFound("OT status not found.");
{
return NotFound("OT status not found.");
}
// ... (rest of the code for checking permissions and logging)
var currentLoggedInUserId = GetCurrentLoggedInUserId(); var currentLoggedInUserId = GetCurrentLoggedInUserId();
string approverRole = GetApproverRole(currentLoggedInUserId, otStatus.StatusId); string approverRole = GetApproverRole(currentLoggedInUserId, otStatus.StatusId);
bool hasApproverActed = false; bool hasApproverActed = false;
switch (approverRole) switch (approverRole)
{ {
case "HoU": case "HoU": hasApproverActed = !string.IsNullOrEmpty(otStatus.HouStatus) && otStatus.HouStatus != "Pending"; break;
hasApproverActed = !string.IsNullOrEmpty(otStatus.HouStatus) && otStatus.HouStatus != "Pending"; case "HoD": hasApproverActed = !string.IsNullOrEmpty(otStatus.HodStatus) && otStatus.HodStatus != "Pending"; break;
break; case "Manager": hasApproverActed = !string.IsNullOrEmpty(otStatus.ManagerStatus) && otStatus.ManagerStatus != "Pending"; break;
case "HoD": case "HR": hasApproverActed = !string.IsNullOrEmpty(otStatus.HrStatus) && otStatus.HrStatus != "Pending"; break;
hasApproverActed = !string.IsNullOrEmpty(otStatus.HodStatus) && otStatus.HodStatus != "Pending";
break;
case "Manager":
hasApproverActed = !string.IsNullOrEmpty(otStatus.ManagerStatus) && otStatus.ManagerStatus != "Pending";
break;
case "HR":
hasApproverActed = !string.IsNullOrEmpty(otStatus.HrStatus) && otStatus.HrStatus != "Pending";
break;
} }
if (hasApproverActed) if (hasApproverActed) return Forbid("You cannot edit this record as you have already acted on this OT submission.");
{
return Forbid("You cannot edit this record as you have already acted on this OT submission.");
}
var originalRecordData = JsonConvert.SerializeObject(existingRecord, new JsonSerializerSettings // Update the Database Record
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
});
// Update the existing record with the new data
existingRecord.OtDate = updatedRecordDto.OtDate; existingRecord.OtDate = updatedRecordDto.OtDate;
existingRecord.OfficeFrom = ParseTimeStringToTimeSpan(updatedRecordDto.OfficeFrom); existingRecord.OfficeFrom = newOfficeFrom;
existingRecord.OfficeTo = ParseTimeStringToTimeSpan(updatedRecordDto.OfficeTo); existingRecord.OfficeTo = newOfficeTo;
existingRecord.OfficeBreak = updatedRecordDto.OfficeBreak; existingRecord.OfficeBreak = updatedRecordDto.OfficeBreak;
existingRecord.AfterFrom = ParseTimeStringToTimeSpan(updatedRecordDto.AfterFrom); existingRecord.AfterFrom = newAfterFrom;
existingRecord.AfterTo = ParseTimeStringToTimeSpan(updatedRecordDto.AfterTo); existingRecord.AfterTo = newAfterTo;
existingRecord.AfterBreak = updatedRecordDto.AfterBreak; existingRecord.AfterBreak = updatedRecordDto.AfterBreak;
existingRecord.StationId = updatedRecordDto.StationId; existingRecord.StationId = updatedRecordDto.StationId;
existingRecord.OtDescription = updatedRecordDto.OtDescription; existingRecord.OtDescription = updatedRecordDto.OtDescription;
_centralDbContext.SaveChanges(); // Create the clean Log
var updateLog = new OtUpdateLog var updateLog = new OtUpdateLog
{ {
ApproverRole = approverRole, ApproverRole = approverRole,
ApproverUserId = currentLoggedInUserId, ApproverUserId = currentLoggedInUserId,
UpdateTimestamp = DateTime.Now, UpdateTimestamp = DateTime.Now,
ChangeType = "Edit", ChangeType = "Edit",
BeforeEdit = JsonConvert.DeserializeObject<OtRegisterModel>(originalRecordData), BeforeEdit = beforeChanges,
AfterEdit = updatedRecordDto AfterEdit = afterChanges
}; };
var logJson = JsonConvert.SerializeObject(updateLog); var logJson = JsonConvert.SerializeObject(updateLog, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
switch (approverRole) switch (approverRole)
{ {
case "HoU": case "HoU": otStatus.HouUpdate = AppendUpdateLog(otStatus.HouUpdate, logJson); break;
otStatus.HouUpdate = AppendUpdateLog(otStatus.HouUpdate, logJson); case "HoD": otStatus.HodUpdate = AppendUpdateLog(otStatus.HodUpdate, logJson); break;
break; case "Manager": otStatus.ManagerUpdate = AppendUpdateLog(otStatus.ManagerUpdate, logJson); break;
case "HoD": case "HR": otStatus.HrUpdate = AppendUpdateLog(otStatus.HrUpdate, logJson); break;
otStatus.HodUpdate = AppendUpdateLog(otStatus.HodUpdate, logJson); default: return Unauthorized("You are not authorized to edit this record.");
break;
case "Manager":
otStatus.ManagerUpdate = AppendUpdateLog(otStatus.ManagerUpdate, logJson);
break;
case "HR":
otStatus.HrUpdate = AppendUpdateLog(otStatus.HrUpdate, logJson);
break;
default:
return Unauthorized("You are not authorized to edit this record.");
} }
_centralDbContext.SaveChanges(); _centralDbContext.SaveChanges();
return Ok(new { message = "Overtime record updated successfully and changes logged." }); return Ok(new { message = "Overtime record updated successfully and changes logged." });
} }
// **NEW HELPER METHOD: IsOverlapping**
private bool IsOverlapping(OtRegisterEditDto updatedRecord, int userId, int currentRecordId) private bool IsOverlapping(OtRegisterEditDto updatedRecord, int userId, int currentRecordId)
{ {
// Filter existing records for the same date, excluding the one being edited
var existingRecords = _centralDbContext.Otregisters var existingRecords = _centralDbContext.Otregisters
.Where(o => o.UserId == userId && o.OtDate.Date == updatedRecord.OtDate.Date && o.OvertimeId != currentRecordId) .Where(o => o.UserId == userId && o.OtDate.Date == updatedRecord.OtDate.Date && o.OvertimeId != currentRecordId)
.ToList(); .ToList();
// Check for overlaps with the new time ranges
TimeSpan? newOfficeFrom = ParseTimeStringToTimeSpan(updatedRecord.OfficeFrom); TimeSpan? newOfficeFrom = ParseTimeStringToTimeSpan(updatedRecord.OfficeFrom);
TimeSpan? newOfficeTo = ParseTimeStringToTimeSpan(updatedRecord.OfficeTo); TimeSpan? newOfficeTo = ParseTimeStringToTimeSpan(updatedRecord.OfficeTo);
TimeSpan? newAfterFrom = ParseTimeStringToTimeSpan(updatedRecord.AfterFrom); TimeSpan? newAfterFrom = ParseTimeStringToTimeSpan(updatedRecord.AfterFrom);
@ -2261,7 +2285,6 @@ namespace PSTW_CentralSystem.Controllers.API
foreach (var existing in existingRecords) foreach (var existing in existingRecords)
{ {
// Check if the new office hours overlap with any existing time range
if (newOfficeFrom.HasValue && newOfficeTo.HasValue) if (newOfficeFrom.HasValue && newOfficeTo.HasValue)
{ {
if (CheckOverlapBetween(newOfficeFrom.Value, newOfficeTo.Value, existing.OfficeFrom, existing.OfficeTo) || if (CheckOverlapBetween(newOfficeFrom.Value, newOfficeTo.Value, existing.OfficeFrom, existing.OfficeTo) ||
@ -2271,7 +2294,6 @@ namespace PSTW_CentralSystem.Controllers.API
} }
} }
// Check if the new after office hours overlap with any existing time range
if (newAfterFrom.HasValue && newAfterTo.HasValue) if (newAfterFrom.HasValue && newAfterTo.HasValue)
{ {
if (CheckOverlapBetween(newAfterFrom.Value, newAfterTo.Value, existing.OfficeFrom, existing.OfficeTo) || if (CheckOverlapBetween(newAfterFrom.Value, newAfterTo.Value, existing.OfficeFrom, existing.OfficeTo) ||
@ -2285,24 +2307,18 @@ namespace PSTW_CentralSystem.Controllers.API
return false; return false;
} }
// **NEW HELPER METHOD: CheckOverlapBetween**
private bool CheckOverlapBetween(TimeSpan start1, TimeSpan end1, TimeSpan? start2, TimeSpan? end2) private bool CheckOverlapBetween(TimeSpan start1, TimeSpan end1, TimeSpan? start2, TimeSpan? end2)
{ {
// If either of the second time range's values is missing, there can be no overlap.
if (!start2.HasValue || !end2.HasValue) if (!start2.HasValue || !end2.HasValue)
{ {
return false; return false;
} }
// Create local, non-nullable variables to work with.
TimeSpan s1 = start1; TimeSpan s1 = start1;
TimeSpan e1 = end1; TimeSpan e1 = end1;
TimeSpan s2 = start2.Value; TimeSpan s2 = start2.Value;
TimeSpan e2 = end2.Value; TimeSpan e2 = end2.Value;
// Handle overnight cases for both ranges by adding 24 hours to the end time
// if it's on or before the start time.
// TimeSpan.FromHours(24) is a cleaner way to express this.
if (e1 <= s1) if (e1 <= s1)
{ {
e1 = e1.Add(TimeSpan.FromHours(24)); e1 = e1.Add(TimeSpan.FromHours(24));
@ -2312,8 +2328,6 @@ namespace PSTW_CentralSystem.Controllers.API
e2 = e2.Add(TimeSpan.FromHours(24)); e2 = e2.Add(TimeSpan.FromHours(24));
} }
// Overlap exists if the start of one range is before the end of the other,
// AND the start of the other is before the end of the first.
return s1 < e2 && s2 < e1; return s1 < e2 && s2 < e1;
} }
@ -2363,10 +2377,13 @@ namespace PSTW_CentralSystem.Controllers.API
return Forbid("You cannot delete this record as you have already acted on this OT submission."); return Forbid("You cannot delete this record as you have already acted on this OT submission.");
} }
var deletedRecordData = JsonConvert.SerializeObject(recordToDelete, new JsonSerializerSettings var cleanDeletedRecord = new
{ {
ReferenceLoopHandling = ReferenceLoopHandling.Ignore OvertimeId = recordToDelete.OvertimeId,
}); OtDate = recordToDelete.OtDate.ToString("yyyy-MM-dd"),
StationId = recordToDelete.StationId?.ToString(),
OtDescription = recordToDelete.OtDescription
};
_centralDbContext.Otregisters.Remove(recordToDelete); _centralDbContext.Otregisters.Remove(recordToDelete);
_centralDbContext.SaveChanges(); _centralDbContext.SaveChanges();
@ -2377,7 +2394,7 @@ namespace PSTW_CentralSystem.Controllers.API
ApproverUserId = currentLoggedInUserId, ApproverUserId = currentLoggedInUserId,
UpdateTimestamp = DateTime.Now, UpdateTimestamp = DateTime.Now,
ChangeType = "Delete", ChangeType = "Delete",
DeletedRecord = JsonConvert.DeserializeObject<OtRegisterModel>(deletedRecordData) DeletedRecord = cleanDeletedRecord
}; };
var logJson = JsonConvert.SerializeObject(deleteLog); var logJson = JsonConvert.SerializeObject(deleteLog);