This commit is contained in:
Naz 2025-04-24 11:45:08 +08:00
parent c0c2c59ea3
commit 4248484877
25 changed files with 353 additions and 415 deletions

View File

@ -12,9 +12,9 @@ namespace PSTW_CentralSystem.Areas.OTcalculate.Controllers
{ {
[Area("OTcalculate")] [Area("OTcalculate")]
[Authorize] [Authorize]
public class HodDashboardController : Controller public class HouDashboardController : Controller
{ {
public IActionResult HodApproval() public IActionResult HouApproval()
{ {
return View(); return View();
} }

View File

@ -0,0 +1,26 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PSTW_CentralSystem.Areas.OTcalculate.Models;
using PSTW_CentralSystem.DBContext;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace PSTW_CentralSystem.Areas.OTcalculate.Controllers
{
[Area("OTcalculate")]
[Authorize]
public class HodDashboardController : Controller
{
public IActionResult HodApproval()
{
return View();
}
public IActionResult OtReview()
{
return View();
}
}
}

View File

@ -20,7 +20,7 @@ namespace PSTW_CentralSystem.Areas.OTcalculate.Controllers
return View(); return View();
} }
public IActionResult OtSTatus() public IActionResult OtStatus()
{ {
return View(); return View();
} }

View File

@ -62,6 +62,7 @@ namespace PSTW_CentralSystem.Areas.OTcalculate.Services
{ {
table.ColumnsDefinition(columns => table.ColumnsDefinition(columns =>
{ {
columns.RelativeColumn(0.7f); // Days
columns.RelativeColumn(1.1f); // Date columns.RelativeColumn(1.1f); // Date
columns.RelativeColumn(0.8f); // Office From columns.RelativeColumn(0.8f); // Office From
columns.RelativeColumn(0.8f); // Office To columns.RelativeColumn(0.8f); // Office To
@ -74,13 +75,13 @@ namespace PSTW_CentralSystem.Areas.OTcalculate.Services
columns.RelativeColumn(); // Net OT columns.RelativeColumn(); // Net OT
if (departmentId == 2) if (departmentId == 2)
columns.RelativeColumn(); // Station columns.RelativeColumn(); // Station
columns.RelativeColumn(0.9f); // Day Type
columns.RelativeColumn(2.7f); // Description columns.RelativeColumn(2.7f); // Description
}); });
table.Header(header => table.Header(header =>
{ {
// Row 1 — grouped headers // Row 1 — grouped headers
header.Cell().RowSpan(2).Background("#e0f7da").Border(0.25f).Padding(5).Text("Days").FontSize(9).Bold().AlignCenter();
header.Cell().RowSpan(2).Background("#d0ead2").Border(0.25f).Padding(5).Text("Date").FontSize(9).Bold().AlignCenter(); header.Cell().RowSpan(2).Background("#d0ead2").Border(0.25f).Padding(5).Text("Date").FontSize(9).Bold().AlignCenter();
header.Cell().ColumnSpan(3).Background("#dceefb").Border(0.25f).Padding(5).Text("Office Hours\n(8:30 - 17:30)").FontSize(9).Bold().AlignCenter(); header.Cell().ColumnSpan(3).Background("#dceefb").Border(0.25f).Padding(5).Text("Office Hours\n(8:30 - 17:30)").FontSize(9).Bold().AlignCenter();
@ -93,17 +94,17 @@ namespace PSTW_CentralSystem.Areas.OTcalculate.Services
if (departmentId == 2) if (departmentId == 2)
header.Cell().RowSpan(2).Background("#d0f0ef").Border(0.25f).Padding(5).Text("Station").FontSize(9).Bold().AlignCenter(); header.Cell().RowSpan(2).Background("#d0f0ef").Border(0.25f).Padding(5).Text("Station").FontSize(9).Bold().AlignCenter();
header.Cell().RowSpan(2).Background("#e0f7da").Border(0.25f).Padding(5).Text("Days").FontSize(9).Bold().AlignCenter();
header.Cell().RowSpan(2).Background("#e3f2fd").Border(0.25f).Padding(5).Text("Description").FontSize(9).Bold().AlignCenter(); header.Cell().RowSpan(2).Background("#e3f2fd").Border(0.25f).Padding(5).Text("Description").FontSize(9).Bold().AlignCenter();
// Row 2 — subheaders only for grouped columns // Row 2 — subheaders only for grouped columns
header.Cell().Background("#dceefb").Border(0.25f).Padding(5).Text("From").FontSize(9).Bold().AlignCenter(); header.Cell().Background("#dceefb").Border(0.25f).Padding(5).Text("From").FontSize(9).Bold().AlignCenter();
header.Cell().Background("#dceefb").Border(0.25f).Padding(5).Text("To").FontSize(9).Bold().AlignCenter(); header.Cell().Background("#dceefb").Border(0.25f).Padding(5).Text("To").FontSize(9).Bold().AlignCenter();
header.Cell().Background("#dceefb").Border(0.25f).Padding(5).Text("Break").FontSize(9).Bold().AlignCenter(); header.Cell().Background("#dceefb").Border(0.25f).Padding(5).Text("Break (min)").FontSize(9).Bold().AlignCenter();
header.Cell().Background("#edf2f7").Border(0.25f).Padding(5).Text("From").FontSize(9).Bold().AlignCenter(); header.Cell().Background("#edf2f7").Border(0.25f).Padding(5).Text("From").FontSize(9).Bold().AlignCenter();
header.Cell().Background("#edf2f7").Border(0.25f).Padding(5).Text("To").FontSize(9).Bold().AlignCenter(); header.Cell().Background("#edf2f7").Border(0.25f).Padding(5).Text("To").FontSize(9).Bold().AlignCenter();
header.Cell().Background("#edf2f7").Border(0.25f).Padding(5).Text("Break").FontSize(9).Bold().AlignCenter(); header.Cell().Background("#edf2f7").Border(0.25f).Padding(5).Text("Break (min)").FontSize(9).Bold().AlignCenter();
}); });
@ -128,44 +129,55 @@ namespace PSTW_CentralSystem.Areas.OTcalculate.Services
} }
else else
{ {
foreach (var r in records) var groupedRecords = records.GroupBy(r => r.OtDate.Date);
foreach (var group in groupedRecords)
{ {
var totalOT = CalculateTotalOT(r); bool isFirstRow = true;
var totalBreak = (r.OfficeBreak ?? 0) + (r.AfterBreak ?? 0);
var netOT = totalOT - TimeSpan.FromMinutes(totalBreak);
totalOTSum += totalOT.TotalHours; foreach (var r in group)
totalBreakSum += totalBreak;
totalNetOt += netOT;
string rowBg = alternate ? "#f9f9f9" : "#ffffff";
alternate = !alternate;
void AddCell(string value, bool alignLeft = false)
{ {
var text = table.Cell().Background(rowBg).Border(0.25f).Padding(5).Text(value).FontSize(9); var totalOT = CalculateTotalOT(r);
if (alignLeft) var totalBreak = (r.OfficeBreak ?? 0) + (r.AfterBreak ?? 0);
text.AlignLeft(); var netOT = totalOT - TimeSpan.FromMinutes(totalBreak);
else
text.AlignCenter();
}
AddCell(r.OtDate.ToString("dd/MM/yyyy")); totalOTSum += totalOT.TotalHours;
AddCell(FormatTime(r.OfficeFrom)); totalBreakSum += totalBreak;
AddCell(FormatTime(r.OfficeTo)); totalNetOt += netOT;
AddCell($"{r.OfficeBreak ?? 0} min");
AddCell(FormatTime(r.AfterFrom)); string rowBg = alternate ? "#f9f9f9" : "#ffffff";
AddCell(FormatTime(r.AfterTo)); alternate = !alternate;
AddCell($"{r.AfterBreak ?? 0} min");
AddCell($"{(int)totalOT.TotalHours} hr {totalOT.Minutes} min"); void AddCell(string value, bool alignLeft = false)
AddCell($"{totalBreak}"); {
AddCell($"{netOT.Hours} hr {netOT.Minutes} min"); var text = table.Cell().Background(rowBg).Border(0.25f).Padding(5).Text(value).FontSize(9);
if (departmentId == 2) if (alignLeft)
AddCell(r.Stations?.StationName ?? "N/A"); text.AlignLeft();
AddCell(r.OtDays); else
table.Cell().Background(rowBg).Border(0.25f).Padding(5).Text(r.OtDescription ?? "-").FontSize(9).WrapAnywhere().LineHeight(1.2f); text.AlignCenter();
}
AddCell(isFirstRow ? $"{r.OtDate:ddd}" : "");
AddCell(isFirstRow ? r.OtDate.ToString("dd/MM/yyyy") : "");
AddCell(FormatTime(r.OfficeFrom));
AddCell(FormatTime(r.OfficeTo));
AddCell($"{r.OfficeBreak ?? 0}");
AddCell(FormatTime(r.AfterFrom));
AddCell(FormatTime(r.AfterTo));
AddCell($"{r.AfterBreak ?? 0}");
AddCell($"{(int)totalOT.TotalHours} hr {totalOT.Minutes} min");
AddCell($"{totalBreak}");
AddCell($"{netOT.Hours} hr {netOT.Minutes} min");
if (departmentId == 2)
AddCell(r.Stations?.StationName ?? "N/A");
table.Cell().Background(rowBg).Border(0.25f).Padding(5).Text(r.OtDescription ?? "-").FontSize(9).WrapAnywhere().LineHeight(1.2f);
isFirstRow = false;
}
} }
var totalOTTimeSpan = TimeSpan.FromHours(totalOTSum); var totalOTTimeSpan = TimeSpan.FromHours(totalOTSum);
var totalBreakTimeSpan = TimeSpan.FromMinutes(totalBreakSum); var totalBreakTimeSpan = TimeSpan.FromMinutes(totalBreakSum);

View File

@ -0,0 +1,5 @@
@{
ViewData["Title"] = "Overtime Approval";
Layout = "~/Views/Shared/_Layout.cshtml";
}

View File

@ -0,0 +1,169 @@
@{
ViewData["Title"] = "Overtime Review";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<div id="ot-review-app" style="max-width: 1300px; margin: auto; font-size: 13px;">
<h2>Overtime Records for {{ reviewedUserName }} - {{ formatMonthYear(selectedMonth, selectedYear) }}</h2>
<div id="print-section" class="table-container table-responsive">
<table class="table table-bordered table-sm table-striped text-center align-middle">
<thead>
</thead>
<tbody>
<tr v-for="(record, index) in otRecords" :key="record.overtimeId">
<td>{{ formatDate(record.otDate) }}</td>
<td>{{ formatTime(record.officeFrom) }}</td>
<td>{{ formatTime(record.officeTo) }}</td>
<td>{{ record.officeBreak }} min</td>
<td>{{ formatTime(record.afterFrom) }}</td>
<td>{{ formatTime(record.afterTo) }}</td>
<td>{{ record.afterBreak }} min</td>
<td>{{ formatHourMinute(calcTotalTime(record)) }}</td>
<td>{{ calcBreakTotal(record) }}</td>
<td>{{ formatHourMinute(calcNetHours(record)) }}</td>
<td v-if="isPSTWAIR">{{ record.stationName || 'N/A' }}</td>
<td>{{ record.otDays}}</td>
<td class="wrap-text">
<div class="description-preview" v-on:click ="toggleDescription(index)" :class="{ expanded: expandedDescriptions[index] }">
{{ record.otDescription }}
</div>
</td>
<td>
<span v-if="record.filePath">
<a :href="record.filePath" target="_blank" class="btn btn-light border rounded-circle" title="View PDF">
<i class="bi bi-file-earmark-pdf-fill fs-5"></i>
</a>
</span>
<span v-else>-</span>
</td>
<td>
<button class="btn btn-success btn-sm me-1">Approve</button>
<button class="btn btn-danger btn-sm">Reject</button>
</td>
</tr>
<tr v-if="otRecords.length === 0">
<td :colspan="isPSTWAIR ? 14 : 13">No overtime records found for this user and period.</td>
</tr>
<tr class="table-primary fw-bold">
<td>TOTAL</td>
<td colspan="6"></td>
<td>{{ formatHourMinute(totalHours) }}</td>
<td>{{ formatHourMinute(totalBreak) }}</td>
<td>{{ formatHourMinute(totalNetTime) }}</td>
<td v-if="isPSTWAIR"></td>
<td colspan="4"></td>
</tr>
</tbody>
</table>
</div>
</div>
@section Scripts {
<script src="https://cdn.jsdelivr.net/npm/vue@3"></script>
<script>
const otReviewApp = Vue.createApp({
data() {
return {
userId: null,
selectedMonth: null,
selectedYear: null,
otRecords: [],
isPSTWAIR: false, // You might need to fetch this based on the reviewed user
expandedDescriptions: {},
reviewedUserName: '' // To display the name of the user being reviewed
};
},
computed: {
totalHours() { /* ... your totalHours calculation ... */ },
totalBreak() { /* ... your totalBreak calculation ... */ },
totalNetTime() { /* ... your totalNetTime calculation ... */ }
},
mounted() {
this.getParamsFromUrl();
this.fetchOtRecords();
this.fetchUserInfo(); // To get the name and department of the reviewed user
},
methods: {
getParamsFromUrl() {
const pathSegments = window.location.pathname.split('/');
this.userId = parseInt(pathSegments[4]); // Assuming the URL structure is /OTcalculate/HodDashboard/OtReview/{userId}/{month}/{year}
this.selectedMonth = parseInt(pathSegments[5]);
this.selectedYear = parseInt(pathSegments[6]);
},
async fetchUserInfo() {
try {
const res = await fetch(`/IdentityAPI/GetUserInformationById/${this.userId}`, { method: 'POST' }); // Assuming you have an endpoint to get user info by ID
if (res.ok) {
const data = await res.json();
this.reviewedUserName = data.userInfo?.fullName;
const department = data.userInfo?.department;
const deptName = department?.departmentName?.toUpperCase?.() || '';
this.isPSTWAIR = deptName.includes("PSTW") && deptName.includes("AIR") && department?.departmentId === 2;
} else {
console.error("Failed to fetch user info:", await res.text());
}
} catch (error) {
console.error("Error fetching user info:", error);
}
},
async fetchOtRecords() {
try {
const res = await fetch(`/OvertimeAPI/GetSubmittedUserOvertimeRecords/<span class="math-inline">\{this\.userId\}/</span>{this.selectedMonth}/${this.selectedYear}`);
if (res.ok) {
this.otRecords = await res.json();
} else {
console.error("Failed to fetch OT records:", await res.text());
alert("Error fetching overtime records.");
}
} catch (error) {
console.error("Error fetching OT records:", error);
alert("An unexpected error occurred.");
}
},
toggleDescription(index) {
this.expandedDescriptions[index] = !this.expandedDescriptions[index];
},
formatDate(d) {
return new Date(d).toLocaleDateString();
},
formatTime(t) {
return t ? t.slice(0, 5) : "-";
},
getTimeDiff(from, to) {
if (!from || !to) return 0;
const [fh, fm] = from.split(":").map(Number);
const [th, tm] = to.split(":").map(Number);
return ((th * 60 + tm) - (fh * 60 + fm)) / 60;
},
calcTotalTime(r) {
const totalMinutes = this.calcTotalHours(r) * 60;
return {
hours: Math.floor(totalMinutes / 60),
minutes: Math.round(totalMinutes % 60)
};
},
calcTotalHours(r) {
return this.getTimeDiff(r.afterFrom, r.officeTo) + this.getTimeDiff(r.afterFrom, r.afterTo);
},
calcBreakTotal(r) {
return (r.officeBreak || 0) + (r.afterBreak || 0);
},
calcNetHours(r) {
const totalMinutes = (this.calcTotalHours(r) * 60) - this.calcBreakTotal(r);
return {
hours: Math.floor(totalMinutes / 60),
minutes: Math.round(totalMinutes % 60)
};
},
formatHourMinute(timeObj) {
return timeObj ? `${timeObj.hours} h ${timeObj.minutes} m` : '-';
},
formatMonthYear(month, year) {
const monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
return `${monthNames[month - 1]} ${year}`;
}
// Add methods for Approve and Reject actions here
}
}).mount("#ot-review-app");
</script>
}

View File

@ -30,16 +30,10 @@
background-color: #fce5cd !important; background-color: #fce5cd !important;
} }
input.form-control { input.form-control,
border-radius: 10px; select.form-control,
}
select.form-control {
border-radius: 10px;
}
.btn { .btn {
border-radius: 10px !important; border-radius: 10px;
font-size: 13px; font-size: 13px;
} }
@ -52,35 +46,32 @@
} }
td.wrap-text { td.wrap-text {
white-space: pre-wrap; /* Keep line breaks and wrap text */ white-space: pre-wrap;
word-wrap: break-word; /* Break long words if necessary */ word-wrap: break-word;
max-width: 300px; /* Adjust as needed */ max-width: 300px;
text-align: left; /* Optional: left-align description */ text-align: left;
} }
.description-preview { .description-preview {
max-height: 3.6em; /* approx. 2 lines */ max-height: 3.6em;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
cursor: pointer; cursor: pointer;
transition: max-height 0.3s ease;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
transition: max-height 0.3s ease;
}
.description-preview.expanded {
max-height: none;
} }
.description-preview.expanded {
max-height: none;
}
</style> </style>
<div id="app" style="max-width: 1300px; margin: auto; font-size: 13px;"> <div id="app" style="max-width: 1300px; margin: auto; font-size: 13px;">
<div class="mb-3 d-flex flex-wrap"> <div class="mb-3 d-flex flex-wrap">
<div class="me-2 mb-2"> <div class="me-2 mb-2">
<label>Month</label> <label>Month</label>
<select class="form-control form-control-sm" v-model="selectedMonth"> <select class="form-control form-control-sm" v-model="selectedMonth">
<option v-for="(m, index) in months" :value="index + 1">{{ m }}</option> <option v-for="(m, i) in months" :value="i + 1">{{ m }}</option>
</select> </select>
</div> </div>
<div class="mb-2"> <div class="mb-2">
@ -92,19 +83,19 @@
</div> </div>
<div id="print-section" class="table-container table-responsive"> <div id="print-section" class="table-container table-responsive">
<table class="table table-bordered table-sm table-striped text-center align-middle"> <table class="table table-bordered table-sm table-striped">
<thead> <thead>
<tr> <tr>
<th class="header-green" rowspan="2">Date</th> <th class="header-green" rowspan="2">Date</th>
<th class="header-blue" colspan="3">Office Hour<br><small>(8:30 - 17:30)</small></th> <th class="header-blue" colspan="3">Office Hour<br><small>(8:30 - 17:30)</small></th>
<th class="header-blue" colspan="3">After Office Hour<br><small>(17:30 - 8:30)</small></th> <th class="header-blue" colspan="3">After Office Hour<br><small>(17:30 - 8:30)</small></th>
<th class="header-orange" rowspan="2">Total OT Hours</th> <th class="header-orange" rowspan="2">Total OT Hours</th>
<th class="header-orange" rowspan="2">Break Hours (min)</th> <th class="header-orange" rowspan="2">Break (min)</th>
<th class="header-orange" rowspan="2">Net OT Hours</th> <th class="header-orange" rowspan="2">Net OT Hours</th>
<th class="header-orange" rowspan="2" v-if="isPSTWAIR">Station</th> <th class="header-orange" rowspan="2" v-if="isPSTWAIR">Station</th>
<th class="header-green" rowspan="2">Days</th> <th class="header-green" rowspan="2">Days</th>
<th class="header-blue" rowspan="2">Description</th> <th class="header-blue" rowspan="2">Description</th>
<th class="header-green" rowspan="2" v-if="!isAlreadySubmitted">Action</th> <th class="header-green" rowspan="2">Action</th>
</tr> </tr>
<tr> <tr>
<th class="header-blue">From</th> <th class="header-blue">From</th>
@ -128,24 +119,23 @@
<td>{{ calcBreakTotal(record) }}</td> <td>{{ calcBreakTotal(record) }}</td>
<td>{{ formatHourMinute(calcNetHours(record)) }}</td> <td>{{ formatHourMinute(calcNetHours(record)) }}</td>
<td v-if="isPSTWAIR">{{ record.stationName || 'N/A' }}</td> <td v-if="isPSTWAIR">{{ record.stationName || 'N/A' }}</td>
<td>{{ record.otDays}}</td> <td>{{ record.otDays }}</td>
<td class="wrap-text"> <td class="wrap-text">
<div class="description-preview" v-on:click ="toggleDescription(index)" :class="{ expanded: expandedDescriptions[index] }"> <div class="description-preview" v-on:click ="toggleDescription(index)" :class="{ expanded: expandedDescriptions[index] }">
{{ record.otDescription }} {{ record.otDescription }}
</div> </div>
</td> </td>
<td v-if="!isAlreadySubmitted"> <td>
<button class="btn btn-light border rounded-circle me-1" title="Edit Record" v-on:click="editRecord(index)"> <button class="btn btn-light border rounded-circle me-1" title="Edit" v-on:click ="editRecord(index)">
<i class="bi bi-pencil-fill text-warning fs-5"></i> <i class="bi bi-pencil-fill text-warning fs-5"></i>
</button> </button>
<button class="btn btn-light border rounded-circle" title="Delete Record" v-on:click="deleteRecord(index)"> <button class="btn btn-light border rounded-circle" title="Delete" v-on:click="deleteRecord(index)">
<i class="bi bi-trash-fill text-danger fs-5"></i> <i class="bi bi-trash-fill text-danger fs-5"></i>
</button> </button>
</td> </td>
</tr> </tr>
<tr v-if="filteredRecords.length === 0"> <tr v-if="filteredRecords.length === 0">
<td :colspan="isPSTWAIR ? 14 : 13">No records found for selected month and year.</td> <td :colspan="isPSTWAIR ? 14 : 13">No records found.</td>
</tr> </tr>
<tr class="table-primary fw-bold"> <tr class="table-primary fw-bold">
<td>TOTAL</td> <td>TOTAL</td>
@ -161,49 +151,22 @@
</div> </div>
<div class="mt-3 d-flex flex-wrap gap-2"> <div class="mt-3 d-flex flex-wrap gap-2">
<button class="btn btn-primary btn-sm" v-on:click="printPdf"> <button class="btn btn-primary btn-sm" v-on:click="printPdf"><i class="bi bi-printer"></i> Print</button>
<i class="bi bi-printer"></i> Print <button class="btn btn-dark btn-sm" v-on:click="downloadPdf"><i class="bi bi-download"></i> Save</button>
</button> <button class="btn btn-success btn-sm" v-on:click=""><i class="bi bi-send"></i> Submit
<button class="btn btn-dark btn-sm" v-on:click="downloadPdf">
<i class="bi bi-download"></i> Save
</button>
<button class="btn btn-success btn-sm"
v-on:click="openSubmitModal"
:disabled="isSubmitting || isAlreadySubmitted">
<i class="bi bi-send"></i> {{ isAlreadySubmitted ? 'Submitted' : 'Submit' }}
</button> </button>
</div> </div>
<!-- Submit Modal -->
<div v-if="showSubmitModal" class="modal show d-block" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content p-3">
<div class="modal-header">
<h5 class="modal-title">Submit OT Records</h5>
<button type="button" class="btn-close" v-on:click="showSubmitModal = false"></button>
</div>
<div class="modal-body">
<input type="file" class="form-control" v-on:change="handleFileChange" accept=".pdf" />
</div>
<div class="modal-footer">
<button class="btn btn-success" v-on:click="submitToHod">Submit</button>
<button class="btn btn-secondary" v-on:click="showSubmitModal = false">Cancel</button>
</div>
</div>
</div>
</div>
</div> </div>
@section Scripts { @section Scripts {
<script src="https://cdn.jsdelivr.net/npm/vue@3"></script> <script src="https://cdn.jsdelivr.net/npm/vue@3"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script> <script>
const app = Vue.createApp({ const app = Vue.createApp({
data() { data() {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
return { return {
otRecords: [], otRecords: [],
userId: null, userId: null,
@ -213,9 +176,6 @@
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
years: Array.from({ length: 10 }, (_, i) => currentYear - 5 + i), years: Array.from({ length: 10 }, (_, i) => currentYear - 5 + i),
expandedDescriptions: {}, expandedDescriptions: {},
showSubmitModal: false,
submitFile: null,
submittedStatus: {},
}; };
}, },
computed: { computed: {
@ -244,10 +204,6 @@
minutes: Math.round(totalMinutes % 60) minutes: Math.round(totalMinutes % 60)
}; };
}, },
isAlreadySubmitted() {
const key = `${this.selectedYear}-${String(this.selectedMonth).padStart(2, '0')}`;
return !!this.submittedStatus[key];
}
}, },
async mounted() { async mounted() {
await this.initUserAndRecords(); await this.initUserAndRecords();
@ -257,19 +213,8 @@
await this.fetchUser(); await this.fetchUser();
if (this.userId) { if (this.userId) {
await this.fetchOtRecords(); await this.fetchOtRecords();
await this.fetchSubmissionStatus();
} }
}, },
async fetchSubmissionStatus() {
try {
const res = await fetch(`/OvertimeAPI/GetSubmissionStatus/${this.userId}`);
const data = await res.json();
this.submittedStatus = data; // expect format like { '2025-04': true }
} catch (err) {
console.error("Submission status fetch error:", err);
}
},
async fetchUser() { async fetchUser() {
try { try {
const res = await fetch('/IdentityAPI/GetUserInformation', { method: 'POST' }); const res = await fetch('/IdentityAPI/GetUserInformation', { method: 'POST' });
@ -346,25 +291,25 @@
} }
}, },
printPdf() { printPdf() {
const today = new Date(); const today = new Date();
const month = today.getMonth() + 1; const month = this.selectedMonth;
const year = today.getFullYear(); const year = this.selectedYear;
fetch(`/OvertimeAPI/GenerateOvertimePdf?month=${month}&year=${year}`) fetch(`/OvertimeAPI/GenerateOvertimePdf?month=${month}&year=${year}`)
.then(response => response.blob()) .then(response => response.blob())
.then(blob => { .then(blob => {
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
const printWindow = window.open(blobUrl, '_blank'); const printWindow = window.open(blobUrl, '_blank');
// Trigger print after window loads // Trigger print after window loads
printWindow.onload = () => { printWindow.onload = () => {
printWindow.focus(); printWindow.focus();
printWindow.print(); printWindow.print();
}; };
}) })
.catch(error => { .catch(error => {
console.error("Error generating PDF:", error); console.error("Error generating PDF:", error);
}); });
}, },
async downloadPdf() { async downloadPdf() {
try { try {
@ -385,57 +330,8 @@
alert("An error occurred while generating the PDF."); alert("An error occurred while generating the PDF.");
} }
}, },
openSubmitModal() {
this.showSubmitModal = true;
},
handleFileChange(event) {
const file = event.target.files[0];
if (file && file.type !== 'application/pdf') {
alert("Only PDF files are allowed.");
return;
}
this.submitFile = file;
},
async submitToHod() {
this.isSubmitting = true;
try {
if (!this.submitFile) {
alert("Please upload a PDF file.");
return;
}
const formData = new FormData();
formData.append("file", this.submitFile);
// Add month & year selection logic if needed
formData.append("month", new Date().getMonth() + 1);
formData.append("year", new Date().getFullYear());
try {
const response = await fetch("/OvertimeAPI/SubmitOvertimeRecords", {
method: "POST",
body: formData
});
if (!response.ok) throw new Error("Submission failed");
alert("Submission successful!");
this.showSubmitModal = false;
const key = `${this.selectedYear}-${String(this.selectedMonth).padStart(2, '0')}`;
this.submittedStatus[key] = true;
this.showSubmitModal = false;
} catch (err) {
alert("Error: " + err.message);
}
} finally {
this.isSubmitting = false;
}
}
} }
}); });
app.mount("#app"); app.mount("#app");
</script> </script>
} }

View File

@ -304,7 +304,7 @@
afterFrom: this.afterFrom ? this.formatTime(this.afterFrom) : null, // Set to null if empty afterFrom: this.afterFrom ? this.formatTime(this.afterFrom) : null, // Set to null if empty
afterTo: this.afterTo ? this.formatTime(this.afterTo) : null, // Set to null if empty afterTo: this.afterTo ? this.formatTime(this.afterTo) : null, // Set to null if empty
afterBreak: this.afterBreak || null, // Make this optional afterBreak: this.afterBreak || null, // Make this optional
stationId: this.isPSTWAIR ? parseInt(this.selectedAirStation) : "", stationId: this.isPSTWAIR ? parseInt(this.selectedAirStation) : null,
otDescription: this.otDescription.trim().split(/\s+/).slice(0, 50).join(' '), otDescription: this.otDescription.trim().split(/\s+/).slice(0, 50).join(' '),
otDays: this.selectedDayType, otDays: this.selectedDayType,
userId: this.userId userId: this.userId

View File

@ -2,122 +2,3 @@
ViewData["Title"] = "Overtime Status"; ViewData["Title"] = "Overtime Status";
Layout = "~/Views/Shared/_Layout.cshtml"; Layout = "~/Views/Shared/_Layout.cshtml";
} }
<style>
.hodstatus, .hrstatus {
text-transform: capitalize;
}
.modal-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0,0,0,0.6);
display: flex;
align-items: center;
justify-content: center;
}
.modal-container {
background: white;
padding: 20px;
border-radius: 8px;
width: 500px;
}
</style>
<div id="otStatusApp" class="container mt-4">
<table class="table table-bordered">
<thead>
<tr>
<th>Month</th>
<th>Year</th>
<th>Submitted On</th>
<th>HOD Status</th>
<th>HR Status</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<tr v-for="status in statusList" :key="status.statusId">
<td>{{ getMonthName(status.month) }}</td>
<td>{{ status.year }}</td>
<td>{{ formatDate(status.submitDate) }}</td>
<td :class="status.hodStatus.toLowerCase()">{{ status.hodStatus }}</td>
<td :class="status.hrStatus.toLowerCase()">{{ status.hrStatus }}</td>
<td>
<button class="btn btn-sm btn-primary" v-on:click ="viewUpdates(status)">View Updates</button>
</td>
</tr>
</tbody>
</table>
<!-- Modal -->
<div v-if="selectedStatus" class="modal-mask">
<div class="modal-container">
<h5>Status History</h5>
<p><strong>HOD Updates:</strong></p>
<ul>
<li v-for="update in parseJson(selectedStatus.hodUpdate)">
{{ formatUpdate(update) }}
</li>
</ul>
<p><strong>HR Updates:</strong></p>
<ul>
<li v-for="update in parseJson(selectedStatus.hrUpdate)">
{{ formatUpdate(update) }}
</li>
</ul>
<button class="btn btn-secondary" v-on:click ="selectedStatus = null">Close</button>
</div>
</div>
</div>
@section Scripts {
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js"></script>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
statusList: [],
selectedStatus: null,
};
},
mounted() {
fetch('/OvertimeAPI/GetUserOtStatus')
.then(res => {
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
return res.json();
})
.then(data => this.statusList = data)
.catch(err => console.error("Fetch error:", err));
},
methods: {
formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString();
},
getMonthName(month) {
return new Date(2000, month - 1, 1).toLocaleString('default', { month: 'long' });
},
parseJson(jsonStr) {
try {
return jsonStr ? JSON.parse(jsonStr) : [];
} catch (e) {
return [];
}
},
formatUpdate(update) {
return `${update.timestamp} - ${update.updatedBy} changed ${update.field} from '${update.oldValue}' to '${update.newValue}'`;
},
viewUpdates(status) {
this.selectedStatus = status;
}
}
}).mount('#otStatusApp');
</script>
}

View File

@ -454,6 +454,7 @@ namespace PSTW_CentralSystem.Controllers.API
#endregion #endregion
#region Ot Records #region Ot Records
[HttpGet("GetUserOvertimeRecords/{userId}")] [HttpGet("GetUserOvertimeRecords/{userId}")]
public IActionResult GetUserOvertimeRecords(int userId) public IActionResult GetUserOvertimeRecords(int userId)
{ {
@ -461,6 +462,7 @@ namespace PSTW_CentralSystem.Controllers.API
{ {
var records = _centralDbContext.Otregisters var records = _centralDbContext.Otregisters
.Where(o => o.UserId == userId) .Where(o => o.UserId == userId)
.OrderByDescending(o => o.OtDate)
.Select(o => new .Select(o => new
{ {
o.OvertimeId, o.OvertimeId,
@ -477,7 +479,6 @@ namespace PSTW_CentralSystem.Controllers.API
o.OtDays, o.OtDays,
o.UserId o.UserId
}) })
.OrderByDescending(o => o.OtDate)
.ToList(); .ToList();
return Ok(records); return Ok(records);
@ -501,7 +502,6 @@ namespace PSTW_CentralSystem.Controllers.API
return NotFound("Overtime record not found."); return NotFound("Overtime record not found.");
} }
// Delete the record from the database (No file handling anymore)
_centralDbContext.Otregisters.Remove(record); _centralDbContext.Otregisters.Remove(record);
_centralDbContext.SaveChanges(); _centralDbContext.SaveChanges();
@ -518,22 +518,18 @@ namespace PSTW_CentralSystem.Controllers.API
[HttpGet("GenerateOvertimePdf")] [HttpGet("GenerateOvertimePdf")]
public IActionResult GenerateOvertimePdf(int month, int year) public IActionResult GenerateOvertimePdf(int month, int year)
{ {
var userIdString = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int userId))
if (string.IsNullOrEmpty(userIdString) || !int.TryParse(userIdString, out int userId))
{
return Unauthorized(); return Unauthorized();
}
// Get user and department info
var user = _centralDbContext.Users var user = _centralDbContext.Users
.Include(u => u.Department) .Include(u => u.Department)
.FirstOrDefault(u => u.Id == userId); .FirstOrDefault(u => u.Id == userId);
if (user == null) if (user == null)
return NotFound("User not found"); return NotFound("User not found.");
var userFullName = $"{user.FullName}"; var fullName = user.FullName;
var departmentId = user.departmentId ?? 0; var departmentId = user.departmentId ?? 0;
var departmentName = user.Department?.DepartmentName ?? "N/A"; var departmentName = user.Department?.DepartmentName ?? "N/A";
@ -542,101 +538,53 @@ namespace PSTW_CentralSystem.Controllers.API
.Where(o => o.UserId == userId && o.OtDate.Month == month && o.OtDate.Year == year) .Where(o => o.UserId == userId && o.OtDate.Month == month && o.OtDate.Year == year)
.ToList(); .ToList();
// Step 1: Generate all days of the month
var daysInMonth = DateTime.DaysInMonth(year, month);
var allDays = Enumerable.Range(1, daysInMonth)
.Select(day => new DateTime(year, month, day))
.ToList();
// Step 2: Merge records with missing days
var mergedRecords = new List<OtRegisterModel>();
foreach (var date in allDays)
{
var dayRecords = records
.Where(r => r.OtDate.Date == date.Date)
.OrderBy(r => r.OfficeFrom)
.ToList();
if (dayRecords.Any())
{
mergedRecords.AddRange(dayRecords);
}
else
{
mergedRecords.Add(new OtRegisterModel
{
OtDate = date,
OtDays = date.DayOfWeek.ToString(),
OtDescription = "",
});
}
}
byte[]? logoImage = null; byte[]? logoImage = null;
var logoPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "images", "logo.jpg"); var logoPath = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "images", "logo.jpg");
if (System.IO.File.Exists(logoPath)) if (System.IO.File.Exists(logoPath))
{
logoImage = System.IO.File.ReadAllBytes(logoPath); logoImage = System.IO.File.ReadAllBytes(logoPath);
}
var stream = _pdfService.GenerateOvertimeTablePdf(records, departmentId, userFullName, departmentName, logoImage); var stream = _pdfService.GenerateOvertimeTablePdf(mergedRecords, departmentId, fullName, departmentName, logoImage);
return File(stream, "application/pdf", $"OvertimeRecords_{year}_{month}.pdf"); return File(stream, "application/pdf", $"OvertimeRecords_{year}_{month}.pdf");
} }
[HttpPost("SubmitOvertimeRecords")]
public async Task<IActionResult> SubmitOvertimeRecords([FromForm] IFormFile file, [FromForm] int month, [FromForm] int year)
{
var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int userId))
return Unauthorized();
if (file == null || file.Length == 0)
return BadRequest("No file uploaded.");
var uploadsFolder = Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "Media", "Overtime");
if (!Directory.Exists(uploadsFolder))
Directory.CreateDirectory(uploadsFolder);
var fileName = $"OT_{userId}_{year}_{month}_{DateTime.Now.Ticks}.pdf";
var filePath = Path.Combine(uploadsFolder, fileName);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await file.CopyToAsync(stream);
}
var statusRecord = new OtStatusModel
{
UserId = userId,
Month = month,
Year = year,
SubmitDate = DateTime.Now,
HodStatus = "Pending",
HrStatus = "Pending",
FilePath = $"/Media/Overtime/{fileName}"
};
_centralDbContext.OtStatus.Add(statusRecord);
await _centralDbContext.SaveChangesAsync();
return Ok(new { message = "Overtime records submitted successfully." });
}
[HttpGet("CheckSubmissionStatus")]
public IActionResult CheckSubmissionStatus(int month, int year)
{
var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int userId))
return Unauthorized();
var isSubmitted = _centralDbContext.OtStatus
.Any(s => s.UserId == userId && s.Month == month && s.Year == year);
return Ok(new { isSubmitted });
}
[HttpGet("GetSubmissionStatus/{userId}")]
public IActionResult GetSubmissionStatus(int userId)
{
try
{
var statuses = _centralDbContext.OtStatus
.Where(s => s.UserId == userId)
.OrderByDescending(s => s.SubmitDate)
.Select(s => new
{
s.UserId,
s.Month,
s.Year,
s.HodStatus,
s.HrStatus,
s.SubmitDate
})
.ToList();
return Ok(statuses);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch submission statuses.");
return StatusCode(500, "Error retrieving submission statuses.");
}
}
#endregion #endregion
#region Ot Edit #region Ot Edit
[HttpGet("GetOvertimeRecordById/{id}")] [HttpGet("GetOvertimeRecordById/{id}")]
public async Task<IActionResult> GetOvertimeRecordById(int id) public async Task<IActionResult> GetOvertimeRecordById(int id)
@ -697,20 +645,7 @@ namespace PSTW_CentralSystem.Controllers.API
#endregion #endregion
#region OtStatus #region OtStatus
[HttpGet("GetUserOtStatus")]
public IActionResult GetUserOtStatus()
{
var userIdStr = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int userId))
return Unauthorized();
var records = _centralDbContext.OtStatus
.Where(s => s.UserId == userId)
.OrderByDescending(s => s.Year).ThenByDescending(s => s.Month)
.ToList();
return Ok(records);
}
#endregion #endregion

View File

@ -561,6 +561,20 @@
</li> </li>
</ul> </ul>
</li> </li>
<li class="sidebar-item">
<a class="sidebar-link has-arrow waves-effect waves-dark"
href="javascript:void(0)"
aria-expanded="false">
<i class="mdi mdi-receipt"></i><span class="hide-menu">HoU Dashboard</span>
</a>
<ul aria-expanded="false" class="collapse first-level">
<li class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" asp-area="OTcalculate" asp-controller="HouDashboard" asp-action="HouApproval" aria-expanded="false">
<i class="mdi mdi-view-dashboard"></i><span class="hide-menu">OT Approval</span>
</a>
</li>
</ul>
</li>
<!-- <li class="sidebar-item"> <!-- <li class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" <a class="sidebar-link waves-effect waves-dark sidebar-link"
href="charts.html" href="charts.html"