Update OT
This commit is contained in:
parent
ed30316685
commit
7ab4706524
3
.gitignore
vendored
3
.gitignore
vendored
@ -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/
|
|
||||||
@ -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() {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user