This commit is contained in:
Naz 2025-06-09 14:41:52 +08:00
parent 19a9ade3eb
commit 5efe8e13c4
31 changed files with 7945 additions and 1420 deletions

View File

@ -26,7 +26,4 @@ namespace PSTW_CentralSystem.Areas.OTcalculate.Models
[ForeignKey("HR")] [ForeignKey("HR")]
public virtual UserModel? HRUser { get; set; } public virtual UserModel? HRUser { get; set; }
} }
} }

View File

@ -1,7 +0,0 @@
namespace PSTW_CentralSystem.Areas.OTcalculate.Models
{
public class OtRecordsModel
{
}
}

View File

@ -46,5 +46,54 @@ namespace PSTW_CentralSystem.Areas.OTcalculate.Models
{ {
return TimeSpan.TryParse(time, out TimeSpan result) ? result : null; return TimeSpan.TryParse(time, out TimeSpan result) ? result : null;
} }
// OtRegisterEditDto.cs
public class OtRegisterEditDto
{
public int OvertimeId { get; set; }
public DateTime OtDate { get; set; }
public string? OfficeFrom { get; set; } // Use string to match input type (e.g., "09:00")
public string? OfficeTo { get; set; } // Use string to match input type
public int? OfficeBreak { get; set; }
public string? AfterFrom { get; set; } // Use string to match input type
public string? AfterTo { get; set; } // Use string to match input type
public int? AfterBreak { get; set; }
public int? StationId { get; set; }
public string? OtDescription { get; set; }
// You might also need to send the StatusId if it's relevant for the update logic
public int StatusId { get; set; }
public string? OtDays { get; set; }
}
public class OtUpdateLog
{
public string ApproverRole { get; set; } // e.g., "HoU", "HoD", "Manager", "HR"
public int ApproverUserId { get; set; }
public DateTime UpdateTimestamp { get; set; }
public string ChangeType { get; set; } // New: "Edit" or "Delete"
// For "Edit" type
public OtRegisterModel? BeforeEdit { get; set; }
public OtRegisterEditDto? AfterEdit { get; set; }
// For "Delete" type
public OtRegisterModel? DeletedRecord { get; set; }
}
public class OtRegisterUpdateDto
{
public int OvertimeId { get; set; }
public DateTime OtDate { get; set; }
public string? OfficeFrom { get; set; }
public string? OfficeTo { get; set; }
public int? OfficeBreak { get; set; }
public string? AfterFrom { get; set; }
public string? AfterTo { get; set; }
public int? AfterBreak { get; set; }
public int? StationId { get; set; }
public string? OtDescription { get; set; }
public string? OtDays { get; set; }
public int UserId { get; set; }
}
} }
} }

View File

@ -9,6 +9,7 @@ namespace PSTW_CentralSystem.Areas.OTcalculate.Models
[Key] [Key]
public int StatusId { get; set; } public int StatusId { get; set; }
public int UserId { get; set; } public int UserId { get; set; }
public int Month { get; set; } public int Month { get; set; }
public int Year { get; set; } public int Year { get; set; }
public DateTime SubmitDate { get; set; } public DateTime SubmitDate { get; set; }

View File

@ -0,0 +1,28 @@
using PSTW_CentralSystem.Models;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PSTW_CentralSystem.Areas.OTcalculate.Models
{
public class StaffSignModel
{
[Key]
public int StaffSignId { get; set; }
public int UserId { get; set; }
[ForeignKey("UserId")]
public UserModel? User { get; set; }
public string? ImagePath { get; set; }
public DateTime? UpdateDate { get; set; }
}
public class ApprovalSignatureData
{
public string ApproverName { get; set; }
public byte[]? SignatureImage { get; set; }
public DateTime? ApprovedDate { get; set; } // New property for the approval date
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,304 +0,0 @@
using ClosedXML.Excel;
using System.IO;
using PSTW_CentralSystem.Models;
using PSTW_CentralSystem.Areas.OTcalculate.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using ClosedXML.Excel.Drawings;
namespace PSTW_CentralSystem.Areas.OTcalculate.Services
{
public class OvertimeExcelService
{
public MemoryStream GenerateOvertimeExcel(
List<OtRegisterModel> records,
int departmentId,
string userFullName,
string departmentName,
int userStateId,
int weekendId,
List<CalendarModel> publicHolidays,
bool isAdminUser = false,
byte[]? logoImage = null
)
{
var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Overtime Records");
int currentRow = 1;
int logoBottomRow = 3;
if (logoImage != null)
{
using (var ms = new MemoryStream(logoImage))
{
var picture = worksheet.AddPicture(ms)
.MoveTo(worksheet.Cell(currentRow, 1))
.WithPlacement(XLPicturePlacement.FreeFloating);
picture.Name = "Company Logo";
picture.Scale(0.3);
}
}
currentRow = logoBottomRow + 1;
int mergeCols = 10; // or set to 'col' if you want to merge all active columns
if (!string.IsNullOrEmpty(userFullName))
{
var nameCell = worksheet.Range(currentRow, 1, currentRow, mergeCols).Merge();
nameCell.Value = $"Name: {userFullName}";
nameCell.Style.Font.Bold = true;
nameCell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Left;
nameCell.Style.Alignment.WrapText = true;
currentRow++;
}
if (!string.IsNullOrEmpty(departmentName))
{
var deptCell = worksheet.Range(currentRow, 1, currentRow, mergeCols).Merge();
deptCell.Value = $"Department: {departmentName}";
deptCell.Style.Font.Bold = true;
deptCell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Left;
deptCell.Style.Alignment.WrapText = true;
currentRow++;
}
currentRow++;
// Header setup
int headerRow1 = currentRow;
int headerRow2 = currentRow + 1;
worksheet.Cell(headerRow1, 1).Value = "Days";
worksheet.Cell(headerRow1, 2).Value = "Date";
worksheet.Range(headerRow1, 1, headerRow2, 1).Merge(); // Days
worksheet.Range(headerRow1, 2, headerRow2, 2).Merge(); // Date
worksheet.Cell(headerRow1, 3).Value = "Office Hours";
worksheet.Range(headerRow1, 3, headerRow1, 5).Merge();
worksheet.Cell(headerRow2, 3).Value = "From";
worksheet.Cell(headerRow2, 4).Value = "To";
worksheet.Cell(headerRow2, 5).Value = "Break (min)";
worksheet.Cell(headerRow1, 6).Value = "After Office Hours";
worksheet.Range(headerRow1, 6, headerRow1, 8).Merge();
worksheet.Cell(headerRow2, 6).Value = "From";
worksheet.Cell(headerRow2, 7).Value = "To";
worksheet.Cell(headerRow2, 8).Value = "Break (min)";
worksheet.Cell(headerRow1, 9).Value = "Total OT Hours";
worksheet.Cell(headerRow1, 10).Value = "Break Hours (min)";
worksheet.Cell(headerRow1, 11).Value = "Net OT Hours";
worksheet.Range(headerRow1, 9, headerRow2, 9).Merge();
worksheet.Range(headerRow1, 10, headerRow2, 10).Merge();
worksheet.Range(headerRow1, 11, headerRow2, 11).Merge();
int col = 12;
if (departmentId == 2 || isAdminUser)
{
worksheet.Cell(headerRow1, col).Value = "Station";
worksheet.Range(headerRow1, col, headerRow2, col).Merge();
col++;
}
worksheet.Cell(headerRow1, col).Value = "Description";
worksheet.Range(headerRow1, col, headerRow2, col).Merge();
// Apply styling after header setup
var headerRange = worksheet.Range(headerRow1, 1, headerRow2, col);
headerRange.Style.Font.Bold = true;
headerRange.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
headerRange.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
headerRange.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
headerRange.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
// Background colors for header cells
worksheet.Range(headerRow1, 1, headerRow2, 2).Style.Fill.BackgroundColor = XLColor.LightGreen;
worksheet.Range(headerRow1, 3, headerRow2, 5).Style.Fill.BackgroundColor = XLColor.AliceBlue;
worksheet.Range(headerRow1, 6, headerRow2, 8).Style.Fill.BackgroundColor = XLColor.AliceBlue;
worksheet.Range(headerRow1, 9, headerRow2, 11).Style.Fill.BackgroundColor = XLColor.Peach;
if (departmentId == 2 || isAdminUser)
worksheet.Range(headerRow1, 12, headerRow2, 12).Style.Fill.BackgroundColor = XLColor.LightCyan;
worksheet.Cell(headerRow1, col).Style.Fill.BackgroundColor = XLColor.LightBlue;
// Update currentRow after headers
currentRow = headerRow2 + 1;
DateTime? previousDate = null;
foreach (var r in records)
{
int dataCol = 1;
bool isSameDateAsPrevious = previousDate == r.OtDate.Date;
previousDate = r.OtDate.Date;
var dayCell = worksheet.Cell(currentRow, dataCol++);
var dateCell = worksheet.Cell(currentRow, dataCol++);
// Check the type of day first
var dayOfWeek = r.OtDate.DayOfWeek;
bool isWeekend = (weekendId == 1 && (dayOfWeek == DayOfWeek.Friday || dayOfWeek == DayOfWeek.Saturday)) ||
(weekendId == 2 && (dayOfWeek == DayOfWeek.Saturday || dayOfWeek == DayOfWeek.Sunday));
bool isPublicHoliday = publicHolidays.Any(h => h.HolidayDate.Date == r.OtDate.Date);
// Apply color regardless of whether the value is shown
if (isPublicHoliday)
{
dayCell.Style.Fill.BackgroundColor = XLColor.Pink;
dateCell.Style.Fill.BackgroundColor = XLColor.Pink;
}
else if (isWeekend)
{
dayCell.Style.Fill.BackgroundColor = XLColor.LightBlue;
dateCell.Style.Fill.BackgroundColor = XLColor.LightBlue;
}
// Show value only if it's not repeated
if (!isSameDateAsPrevious)
{
dayCell.Value = r.OtDate.ToString("ddd");
dateCell.Value = r.OtDate.ToString("yyyy-MM-dd");
}
else
{
dayCell.Value = "";
dateCell.Value = "";
}
worksheet.Cell(currentRow, dataCol++).Value = FormatTime(r.OfficeFrom);
worksheet.Cell(currentRow, dataCol++).Value = FormatTime(r.OfficeTo);
worksheet.Cell(currentRow, dataCol++).Value = r.OfficeBreak;
worksheet.Cell(currentRow, dataCol++).Value = FormatTime(r.AfterFrom);
worksheet.Cell(currentRow, dataCol++).Value = FormatTime(r.AfterTo);
worksheet.Cell(currentRow, dataCol++).Value = r.AfterBreak;
TimeSpan totalOT = CalculateTotalOT(r);
int totalBreak = (r.OfficeBreak ?? 0) + (r.AfterBreak ?? 0);
TimeSpan netOT = totalOT - TimeSpan.FromMinutes(totalBreak);
var totalOTCell = worksheet.Cell(currentRow, dataCol++);
totalOTCell.Value = totalOT;
totalOTCell.Style.NumberFormat.Format = "hh:mm";
worksheet.Cell(currentRow, dataCol++).Value = totalBreak;
var netOTCell = worksheet.Cell(currentRow, dataCol++);
netOTCell.Value = netOT;
netOTCell.Style.NumberFormat.Format = "hh:mm";
if (departmentId == 2 || isAdminUser)
worksheet.Cell(currentRow, dataCol++).Value = r.Stations?.StationName ?? "";
worksheet.Cell(currentRow, dataCol++).Value = r.OtDescription ?? "";
for (int i = 1; i <= col; i++)
{
var cell = worksheet.Cell(currentRow, i);
cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
cell.Style.Alignment.Vertical = XLAlignmentVerticalValues.Center;
cell.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
cell.Style.Border.InsideBorder = XLBorderStyleValues.Thin;
}
currentRow++;
}
if (records.Any())
{
int totalRow = currentRow;
worksheet.Cell(totalRow, 1).Value = "TOTAL";
worksheet.Cell(totalRow, 1).Style.Font.Bold = true;
int colTotalOT = 9; // Column for Total OT
int colBreakMin = 10; // Column for Break Hours (min)
int colNetOT = 11; // Column for Net OT
var totalOTSumCell = worksheet.Cell(totalRow, colTotalOT);
totalOTSumCell.FormulaA1 = $"SUM({GetColumnLetter(colTotalOT)}{headerRow1 + 1}:{GetColumnLetter(colTotalOT)}{totalRow - 1})";
totalOTSumCell.Style.NumberFormat.Format = "hh:mm";
var breakMinTotalCell = worksheet.Cell(totalRow, colBreakMin);
breakMinTotalCell.FormulaA1 = $"SUM({GetColumnLetter(colBreakMin)}{headerRow1 + 1}:{GetColumnLetter(colBreakMin)}{totalRow - 1})/1440";
breakMinTotalCell.Style.NumberFormat.Format = "hh:mm";
var netOTSumCell = worksheet.Cell(totalRow, colNetOT);
netOTSumCell.FormulaA1 = $"SUM({GetColumnLetter(colNetOT)}{headerRow1 + 1}:{GetColumnLetter(colNetOT)}{totalRow - 1})";
netOTSumCell.Style.NumberFormat.Format = "hh:mm";
for (int i = 1; i <= col; i++)
{
var cell = worksheet.Cell(totalRow, i);
cell.Style.Font.Bold = true;
cell.Style.Fill.BackgroundColor = XLColor.Yellow;
cell.Style.Border.OutsideBorder = XLBorderStyleValues.Thin;
cell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
}
}
// Step 1: Enable wrap text on header range
headerRange.Style.Alignment.WrapText = true;
worksheet.Style.Alignment.WrapText = true;
// Step 2: Adjust row height based on wrap text
worksheet.Rows(headerRow1, headerRow2).AdjustToContents();
// Step 3 (optional): Manually set column widths to ensure long headers show correctly
worksheet.Column(1).Width = 8; // Days
worksheet.Column(2).Width = 13; // Date
worksheet.Column(3).Width = 12; // Office From
worksheet.Column(4).Width = 12; // Office To
worksheet.Column(5).Width = 12; // Office Break
worksheet.Column(6).Width = 12; // After From
worksheet.Column(7).Width = 12; // After To
worksheet.Column(8).Width = 12; // After Break
worksheet.Column(9).Width = 14; // Total OT Hours
worksheet.Column(10).Width = 15.5f; // Break Hours
worksheet.Column(11).Width = 14; // Net OT Hours
int colIndex = 12;
if (departmentId == 2 || isAdminUser)
{
worksheet.Column(colIndex++).Width = 20; // Station
}
worksheet.Column(colIndex).Width = 35; // Description
var stream = new MemoryStream();
workbook.SaveAs(stream);
stream.Position = 0;
return stream;
}
private TimeSpan CalculateTotalOT(OtRegisterModel r)
{
TimeSpan office = (r.OfficeTo ?? TimeSpan.Zero) - (r.OfficeFrom ?? TimeSpan.Zero);
TimeSpan after = (r.AfterTo ?? TimeSpan.Zero) - (r.AfterFrom ?? TimeSpan.Zero);
if (after < TimeSpan.Zero)
after += TimeSpan.FromHours(24);
return office + after;
}
private string FormatTime(TimeSpan? time)
{
return time == null || time == TimeSpan.Zero ? "" : time.Value.ToString(@"hh\:mm");
}
private string GetColumnLetter(int columnNumber)
{
string columnString = "";
while (columnNumber > 0)
{
int currentLetterNumber = (columnNumber - 1) % 26;
char currentLetter = (char)(currentLetterNumber + 65);
columnString = currentLetter + columnString;
columnNumber = (columnNumber - 1) / 26;
}
return columnString;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,237 +0,0 @@
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using System.IO;
using System.Collections.Generic;
using PSTW_CentralSystem.Areas.OTcalculate.Models;
using System;
using System.Linq;
namespace PSTW_CentralSystem.Areas.OTcalculate.Services
{
public class OvertimePdfService
{
public MemoryStream GenerateOvertimeTablePdf(
List<OtRegisterModel> records,
int departmentId,
string userFullName,
string departmentName,
int userStateId,
int weekendId,
List<CalendarModel> publicHolidays,
bool isAdminUser = false,
byte[]? logoImage = null)
{
records = records.OrderBy(r => r.OtDate).ToList();
var stream = new MemoryStream();
Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4.Landscape());
page.Margin(30);
page.Content().Column(column =>
{
// Header
column.Item().Row(row =>
{
row.RelativeItem(2).Column(col =>
{
if (logoImage != null)
{
col.Item().Container().Height(36).Image(logoImage, ImageScaling.FitArea);
col.Spacing(10);
}
col.Item().Text($"Name: {userFullName}").FontSize(9).SemiBold();
col.Item().Text($"Department: {departmentName}").FontSize(9).Italic();
col.Item().Text($"Overtime Record: {GetMonthYearString(records)}").FontSize(9).Italic();
});
row.RelativeItem(1).AlignRight().Text($"Generated: {DateTime.Now:dd MMM yyyy HH:mm}")
.FontSize(9).FontColor(Colors.Grey.Medium);
});
column.Item().PaddingVertical(10).LineHorizontal(0.5f).LineColor(Colors.Grey.Lighten2);
// Table
column.Item().Table(table =>
{
// Columns
table.ColumnsDefinition(columns =>
{
columns.RelativeColumn(0.7f); // Day
columns.RelativeColumn(1.1f); // Date
columns.RelativeColumn(0.8f); // Office From
columns.RelativeColumn(0.8f); // Office To
columns.RelativeColumn(0.8f); // Office Break
columns.RelativeColumn(0.9f); // After From
columns.RelativeColumn(0.9f); // After To
columns.RelativeColumn(0.9f); // After Break
columns.RelativeColumn(); // Total OT
columns.RelativeColumn(); // Break Hours
columns.RelativeColumn(); // Net OT
if (departmentId == 2 || isAdminUser)
columns.RelativeColumn(); // Station
columns.RelativeColumn(2.7f); // Description
});
// Header
table.Header(header =>
{
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().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("#edf2f7").Border(0.25f).Padding(5).Text("After Office Hours\n(17:30 - 8:30)").FontSize(9).Bold().AlignCenter();
header.Cell().RowSpan(2).Background("#fdebd0").Border(0.25f).Padding(5).Text("Total OT\nHours").FontSize(9).Bold().AlignCenter();
header.Cell().RowSpan(2).Background("#fdebd0").Border(0.25f).Padding(5).Text("Break Hours\n(min)").FontSize(9).Bold().AlignCenter();
header.Cell().RowSpan(2).Background("#fdebd0").Border(0.25f).Padding(5).Text("Net OT\nHours").FontSize(9).Bold().AlignCenter();
if (departmentId == 2 || isAdminUser)
header.Cell().RowSpan(2).Background("#d0f0ef").Border(0.25f).Padding(5).Text("Station").FontSize(9).Bold().AlignCenter();
header.Cell().RowSpan(2).Background("#e3f2fd").Border(0.25f).Padding(5).Text("Description").FontSize(9).Bold().AlignCenter();
// Subheaders for Office/After
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("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("To").FontSize(9).Bold().AlignCenter();
header.Cell().Background("#edf2f7").Border(0.25f).Padding(5).Text("Break (min)").FontSize(9).Bold().AlignCenter();
});
// Data
double totalOTSum = 0;
int totalBreakSum = 0;
TimeSpan totalNetOt = TimeSpan.Zero;
bool alternate = false;
if (!records.Any())
{
uint colspan = (uint)(departmentId == 2 ? 13 : 12);
table.Cell().ColumnSpan(colspan).Border(0.5f).Padding(10).AlignCenter()
.Text("No records found for selected month and year.")
.FontSize(10).FontColor(Colors.Grey.Darken2).Italic();
}
else
{
foreach (var r in records)
{
var totalOT = CalculateTotalOT(r);
var totalBreak = (r.OfficeBreak ?? 0) + (r.AfterBreak ?? 0);
var netOT = totalOT - TimeSpan.FromMinutes(totalBreak);
totalOTSum += totalOT.TotalHours;
totalBreakSum += totalBreak;
totalNetOt += netOT;
string rowBg = alternate ? "#f9f9f9" : "#ffffff";
alternate = !alternate;
string backgroundColor = GetDayCellBackgroundColor(r.OtDate, userStateId, publicHolidays, weekendId);
void AddCell(string value, bool center = true, string? bg = null)
{
var text = table.Cell().Background(bg ?? rowBg).Border(0.25f).Padding(5).Text(value).FontSize(9);
if (center)
text.AlignCenter();
else
text.AlignLeft();
}
AddCell(r.OtDate.ToString("ddd"), true, backgroundColor);
AddCell(r.OtDate.ToString("dd/MM/yyyy"));
AddCell(FormatTime(r.OfficeFrom));
AddCell(FormatTime(r.OfficeTo));
AddCell(r.OfficeBreak > 0 ? $"{r.OfficeBreak}" : "");
AddCell(FormatTime(r.AfterFrom));
AddCell(FormatTime(r.AfterTo));
AddCell(r.AfterBreak > 0 ? $"{r.AfterBreak}" : "");
AddCell(totalOT > TimeSpan.Zero ? $"{totalOT:hh\\:mm}" : "");
AddCell(totalBreak > 0 ? $"{totalBreak}" : "");
AddCell(netOT > TimeSpan.Zero ? $"{netOT:hh\\:mm}" : "");
if (departmentId == 2 || isAdminUser)
AddCell(r.Stations?.StationName ?? "");
table.Cell().Background(rowBg).Border(0.25f).Padding(5)
.Text(r.OtDescription ?? "-").FontSize(9).WrapAnywhere().LineHeight(1.2f);
}
// Totals row
int totalCols = departmentId == 2 ? 13 : 12;
int spanCols = departmentId == 2 ? 9 : 8;
for (int i = 0; i < spanCols; i++)
table.Cell().Background("#d8d1f5").Border(0.80f).Padding(5)
.Text(i == 0 ? "TOTAL" : "").Bold().FontSize(9).AlignCenter();
table.Cell().Background("#d8d1f5").Border(0.80f).Padding(5)
.Text($"{TimeSpan.FromHours(totalOTSum):hh\\:mm}").Bold().FontSize(9).AlignCenter();
table.Cell().Background("#d8d1f5").Border(0.80f).Padding(5)
.Text($"{TimeSpan.FromMinutes(totalBreakSum):hh\\:mm}").Bold().FontSize(9).AlignCenter();
table.Cell().Background("#d8d1f5").Border(0.80f).Padding(5)
.Text($"{totalNetOt:hh\\:mm}").Bold().FontSize(9).AlignCenter();
if (departmentId == 2 || isAdminUser)
table.Cell().Background("#d8d1f5").Border(0.80f);
table.Cell().Background("#d8d1f5").Border(0.80f);
}
});
});
});
}).GeneratePdf(stream);
stream.Position = 0;
return stream;
}
private TimeSpan CalculateTotalOT(OtRegisterModel r)
{
TimeSpan office = (r.OfficeTo ?? TimeSpan.Zero) - (r.OfficeFrom ?? TimeSpan.Zero);
TimeSpan after = (r.AfterTo ?? TimeSpan.Zero) - (r.AfterFrom ?? TimeSpan.Zero);
if (after < TimeSpan.Zero)
after += TimeSpan.FromHours(24);
return office + after;
}
private string FormatTime(TimeSpan? time)
{
if (time == null || time == TimeSpan.Zero)
return "";
return time.Value.ToString(@"hh\:mm");
}
private string GetMonthYearString(List<OtRegisterModel> records)
{
if (records == null || !records.Any())
return "No Data";
var firstDate = records.First().OtDate;
return $"{firstDate:MMMM yyyy}";
}
private string GetDayCellBackgroundColor(DateTime date, int userStateId, List<CalendarModel> publicHolidays, int weekendId)
{
if (publicHolidays.Any(h => h.HolidayDate.Date == date.Date))
return "#ffc0cb"; // Pink
var dayOfWeek = date.DayOfWeek;
if ((weekendId == 1 && (dayOfWeek == DayOfWeek.Friday || dayOfWeek == DayOfWeek.Saturday)) ||
(weekendId == 2 && (dayOfWeek == DayOfWeek.Saturday || dayOfWeek == DayOfWeek.Sunday)))
return "#add8e6"; // Blue
return "#ffffff"; // Default
}
}
}

View File

@ -4,6 +4,7 @@
} }
<style> <style>
/* Your existing CSS styles remain here */
body { body {
background-color: #f3f4f6; background-color: #f3f4f6;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
@ -14,75 +15,256 @@
border-radius: 10px; border-radius: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
padding: 25px; padding: 25px;
margin-top: 30px; margin-top: 20px; /* Adjusted margin-top for table-layer */
border: 1px solid #e0e0e0; border: 1px solid #e0e0e0;
} }
.table-container table { .table-container table {
width: 100%; width: 100%;
margin-bottom: 0; /* Remove default table margin */
} }
.header { .header {
background-color: #007bff; background-color: #007bff;
color: white; color: white;
text-align: center; text-align: center;
vertical-align: middle; /* Center header text vertically */
} }
.btn-sm { .btn-sm {
font-size: 0.75rem; font-size: 0.75rem;
} }
/* Styles for status badges */
.badge-pending {
background-color: #ffc107;
color: #343a40;
}
.badge-approved {
background-color: #28a745;
color: white;
}
.badge-rejected {
background-color: #dc3545;
color: white;
}
.badge-secondary {
background-color: #6c757d;
color: white;
}
/* Custom style for action column buttons */
.action-buttons button {
margin-right: 5px;
}
.action-buttons button:last-child {
margin-right: 0;
}
/* Sorting indicator styles */
.sortable-header {
cursor: pointer;
user-select: none;
position: relative;
padding-right: 20px;
}
.sortable-header .sort-icon {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
font-size: 0.8em;
}
.pagination-controls {
display: flex;
flex-wrap: wrap; /* Allow wrapping on smaller screens */
justify-content: space-between;
align-items: center;
padding: 10px 0; /* Add padding for separation */
border-top: 1px solid #e9ecef; /* Light border to separate from table */
margin-top: 15px; /* Margin above pagination controls */
}
.pagination-info {
font-size: 0.85em;
color: #555;
margin-right: 15px; /* Space between info and pagination links */
}
/* Adjustments for "Items per page" dropdown */
.pagination-items-per-page {
display: flex;
align-items: center;
}
.pagination-items-per-page label {
white-space: nowrap; /* Prevent label from wrapping */
}
/* New style for the formal overall pending notice */
.formal-pending-notice {
margin-top: 15px;
margin-bottom: 20px;
padding: 12px 20px; /* Slightly more padding for formal look */
border-radius: 8px; /* More rounded corners */
background-color: #f8d7da; /* Light red/danger background for attention */
border: 1px solid #dc3545; /* Deeper red border */
color: #721c24; /* Dark red text for formal warning */
font-weight: bold;
text-align: left; /* Align text to the left */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); /* Subtle shadow */
}
</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="row mb-3 align-items-end">
<div class="me-2 mb-2"> <div class="col-md-auto me-3">
<label>Month</label> <label for="monthSelect" class="form-label mb-1">Month</label>
<select class="form-control form-control-sm" v-model="selectedMonth" v-on:change="loadData"> <select id="monthSelect" class="form-control form-control-sm" v-model="selectedMonth" v-on:change="loadData">
<option v-for="(m, i) in months" :value="i + 1">{{ m }}</option> <option v-for="(m, i) in months" :value="i + 1">{{ m }}</option>
</select> </select>
</div> </div>
<div class="mb-2"> <div class="col-md-auto">
<label>Year</label> <label for="yearSelect" class="form-label mb-1">Year</label>
<select class="form-control form-control-sm" v-model="selectedYear" v-on:change="loadData"> <select id="yearSelect" class="form-control form-control-sm" v-model="selectedYear" v-on:change="loadData">
<option v-for="y in years" :value="y">{{ y }}</option> <option v-for="y in years" :value="y">{{ y }}</option>
</select> </select>
</div> </div>
</div> </div>
<div v-if="overallPendingMonths.length > 0 && activeTab === 'pending'" class="formal-pending-notice">
<b>Pending Action :</b> {{ overallPendingMonths.join(', ') }}
</div>
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link" :class="{ active: activeTab === 'pending' }" v-on:click="activeTab = 'pending'" href="#">
Pending Actions <span class="badge bg-warning text-dark">{{ pendingActionsCount }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" :class="{ active: activeTab === 'completed' }" v-on:click="activeTab = 'completed'" href="#">
Completed Actions <span class="badge bg-info">{{ completedActionsCount }}</span>
</a>
</li>
</ul>
<div class="table-layer"> <div class="table-layer">
<div class="mb-3">
<input type="text" class="form-control form-control-sm" placeholder="Search by Staff Name or Status..." v-model="searchQuery" />
</div>
<div class="table-container table-responsive"> <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>
<th class="header">Staff Name</th> <th class="header sortable-header" v-on:click="sortBy('fullName')">
<th class="header">Date Submit</th> Staff Name
<th class="header" v-if="userRoles.includes('HoU')">HoU Status</th> <span class="sort-icon">
<th class="header" v-if="userRoles.includes('HoD')">HoD Status</th> <template v-if="sortByColumn === 'fullName'">
<th class="header" v-if="userRoles.includes('Manager')">Manager Status</th> {{ sortDirection === 'asc' ? '&#9650;' : '&#9660;' }}
<th class="header" v-if="userRoles.includes('HR')">HR Status</th> </template>
</span>
</th>
<th class="header sortable-header" v-on:click="sortBy('submitDate')">
Date Submit
<span class="sort-icon">
<template v-if="sortByColumn === 'submitDate'">
{{ sortDirection === 'asc' ? '&#9650;' : '&#9660;' }}
</template>
</span>
</th>
<th class="header sortable-header" v-on:click="sortBy('currentUserStatus')">
Status
<span class="sort-icon">
<template v-if="sortByColumn === 'currentUserStatus'">
{{ sortDirection === 'asc' ? '&#9650;' : '&#9660;' }}
</template>
</span>
</th>
<th class="header">Action</th> <th class="header">Action</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="row in otStatusList" :key="row.statusId"> <tr v-for="row in paginatedData" :key="row.statusId">
<td>{{ row.fullName }}</td> <td>{{ row.fullName }}</td>
<td>{{ formatDate(row.submitDate) }}</td> <td>{{ formatDate(row.submitDate) }}</td>
<td v-if="userRoles.includes('HoU')">{{ row.houStatus }}</td>
<td v-if="userRoles.includes('HoD')">{{ row.hodStatus }}</td>
<td v-if="userRoles.includes('Manager')">{{ row.managerStatus }}</td>
<td v-if="userRoles.includes('HR')">{{ row.hrStatus }}</td>
<td> <td>
<button class="btn btn-success btn-sm me-1" v-on:click="updateStatus(row.statusId, 'Approved')">Approve</button> <div v-if="row.role === 'HoU'">HoU: <span :class="getStatusBadgeClass(row.houStatus)">{{ row.houStatus }}</span></div>
<button class="btn btn-danger btn-sm me-1" v-on:click="updateStatus(row.statusId, 'Rejected')">Reject</button> <div v-else-if="row.role === 'HoD'">HoD: <span :class="getStatusBadgeClass(row.hodStatus)">{{ row.hodStatus }}</span></div>
<button class="btn btn-primary btn-sm" v-on:click="viewOtData(row.statusId)">View</button> <div v-else-if="row.role === 'Manager'">Manager: <span :class="getStatusBadgeClass(row.managerStatus)">{{ row.managerStatus }}</span></div>
<div v-else-if="row.role === 'HR'">HR: <span :class="getStatusBadgeClass(row.hrStatus)">{{ row.hrStatus }}</span></div>
<div v-if="row.IsOverallRejected && (row.currentUserStatus !== 'Approved' && row.currentUserStatus !== 'Rejected')" class="mt-1">
<span class="badge bg-danger">Rejected by a previous approver</span>
</div>
</td> </td>
<td>
<div class="d-flex align-items-center justify-content-center action-buttons">
<template v-if="activeTab === 'pending'">
<template v-if="row.canApprove">
<button class="btn btn-success btn-sm" v-on:click="updateStatus(row.statusId, 'Approved')">Approve</button>
<button class="btn btn-danger btn-sm" v-on:click="updateStatus(row.statusId, 'Rejected')">Reject</button>
</template>
<template v-else-if="!row.canApprove && row.IsOverallRejected">
<span class="badge bg-danger">Rejected</span>
</template>
<template v-else-if="!row.canApprove">
<span class="badge bg-secondary">Awaiting previous approval</span>
</template>
</template>
<template v-else-if="activeTab === 'completed'"></template>
<button class="btn btn-primary btn-sm" v-on:click="viewOtData(row.statusId)">View</button>
</div>
</td>
</tr>
<tr v-if="paginatedData.length === 0">
<td colspan="4" class="text-center">No {{ activeTab === 'pending' ? 'pending' : 'completed' }} actions found for your current filters.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="pagination-controls">
<div class="pagination-info">
Showing {{ (currentPage - 1) * itemsPerPage + 1 }} to {{ Math.min(currentPage * itemsPerPage, filteredAndSortedOtStatusList.length) }} of {{ filteredAndSortedOtStatusList.length }} entries
</div>
<nav aria-label="Page navigation" class="mx-auto">
<ul class="pagination pagination-sm mb-0">
<li class="page-item" :class="{ disabled: currentPage === 1 }">
<a class="page-link" href="#" v-on:click.prevent="currentPage--" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
<li class="page-item" v-for="page in totalPages" :key="page" :class="{ active: currentPage === page }">
<a class="page-link" href="#" v-on:click.prevent="currentPage = page">{{ page }}</a>
</li>
<li class="page-item" :class="{ disabled: currentPage === totalPages }">
<a class="page-link" href="#" v-on:click.prevent="currentPage++" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
<div class="pagination-items-per-page">
<label class="me-2 mb-0">Items per page:</label>
<select class="form-select form-select-sm" v-model.number="itemsPerPage">
<option :value="5">5</option>
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
<option :value="filteredAndSortedOtStatusList.length">All</option>
</select>
</div>
</div>
</div> </div>
</div> </div>
@ -92,12 +274,105 @@
return { return {
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) => new Date().getFullYear() - 5 + i), years: Array.from({ length: 10 }, (_, i) => new Date().getFullYear() - 5 + i),
selectedMonth: new Date().getMonth() + 1, selectedMonth: parseInt(sessionStorage.getItem('approvalSelectedMonth')) || (new Date().getMonth() + 1),
selectedYear: new Date().getFullYear(), selectedYear: parseInt(sessionStorage.getItem('approvalSelectedYear')) || new Date().getFullYear(),
otStatusList: [], otStatusList: [],
userRoles: [] activeTab: 'pending',
userRoles: [],
sortByColumn: 'submitDate',
sortDirection: 'desc',
searchQuery: '',
currentPage: 1,
itemsPerPage: 10,
overallPendingMonths: [] // To store the list of "MM/YYYY" strings
}; };
}, },
watch: {
activeTab() {
this.currentPage = 1;
this.searchQuery = '';
},
searchQuery() {
this.currentPage = 1;
},
itemsPerPage() {
this.currentPage = 1;
}
},
computed: {
filteredByTabOtStatusList() {
if (this.activeTab === 'pending') {
return this.otStatusList.filter(row => row.canApprove || (row.IsOverallRejected && (row.currentUserStatus !== 'Approved' && row.currentUserStatus !== 'Rejected')));
} else if (this.activeTab === 'completed') {
return this.otStatusList.filter(row => row.currentUserStatus === 'Approved' || row.currentUserStatus === 'Rejected');
}
return [];
},
searchedOtStatusList() {
if (!this.searchQuery) {
return this.filteredByTabOtStatusList;
}
const query = this.searchQuery.toLowerCase();
return this.filteredByTabOtStatusList.filter(row => {
if (row.fullName && row.fullName.toLowerCase().includes(query)) return true;
if (row.currentUserStatus && row.currentUserStatus.toLowerCase().includes(query)) return true;
if (row.houStatus && row.houStatus.toLowerCase().includes(query)) return true;
if (row.hodStatus && row.hodStatus.toLowerCase().includes(query)) return true;
if (row.managerStatus && row.managerStatus.toLowerCase().includes(query)) return true;
if (row.hrStatus && row.hrStatus.toLowerCase().includes(query)) return true;
return false;
});
},
filteredAndSortedOtStatusList() {
let sortedList = [...this.searchedOtStatusList];
if (this.sortByColumn) {
sortedList.sort((a, b) => {
let valA = a[this.sortByColumn];
let valB = b[this.sortByColumn];
if (this.sortByColumn === 'currentUserStatus') {
const statusOrder = { 'Pending': 1, 'Approved': 2, 'Rejected': 3, 'N/A': 4, null: 5, undefined: 6, '': 7 };
const orderA = statusOrder[valA] || 99;
const orderB = statusOrder[valB] || 99;
if (this.sortDirection === 'asc') return orderA - orderB;
else return orderB - orderA;
}
else if (this.sortByColumn === 'submitDate') {
valA = valA ? new Date(valA) : new Date(0);
valB = valB ? new Date(valB) : new Date(0);
if (valA < valB) return this.sortDirection === 'asc' ? -1 : 1;
if (valA > valB) return this.sortDirection === 'asc' ? 1 : -1;
}
else if (typeof valA === 'string' && typeof valB === 'string') {
valA = valA.toLowerCase();
valB = valB.toLowerCase();
if (valA < valB) return this.sortDirection === 'asc' ? -1 : 1;
if (valA > valB) return this.sortDirection === 'asc' ? 1 : -1;
}
else {
if (valA < valB) return this.sortDirection === 'asc' ? -1 : 1;
if (valA > valB) return this.sortDirection === 'asc' ? 1 : -1;
}
return 0;
});
}
return sortedList;
},
totalPages() {
return Math.ceil(this.filteredAndSortedOtStatusList.length / this.itemsPerPage);
},
paginatedData() {
const start = (this.currentPage - 1) * this.itemsPerPage;
const end = start + this.itemsPerPage;
return this.filteredAndSortedOtStatusList.slice(start, end);
},
pendingActionsCount() {
return this.otStatusList.filter(row => row.canApprove).length;
},
completedActionsCount() {
return this.otStatusList.filter(row => row.currentUserStatus === 'Approved' || row.currentUserStatus === 'Rejected').length;
}
},
methods: { methods: {
loadData() { loadData() {
fetch(`/OvertimeAPI/GetPendingApproval?month=${this.selectedMonth}&year=${this.selectedYear}`) fetch(`/OvertimeAPI/GetPendingApproval?month=${this.selectedMonth}&year=${this.selectedYear}`)
@ -108,19 +383,31 @@
.then(result => { .then(result => {
this.userRoles = result.roles; this.userRoles = result.roles;
this.otStatusList = result.data; this.otStatusList = result.data;
this.overallPendingMonths = result.overallPendingMonths || [];
this.currentPage = 1;
}) })
.catch(err => { .catch(err => {
console.error("Error loading data:", err); console.error("Error loading data:", err);
alert("Error loading data: " + err.message);
}); });
}, },
formatDate(dateStr) { formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr); const d = new Date(dateStr);
return d.toLocaleDateString(); return d.toLocaleDateString();
}, },
getStatusBadgeClass(status) {
switch (status) {
case 'Approved': return 'badge badge-approved';
case 'Rejected': return 'badge badge-rejected';
case 'Pending': return 'badge badge-pending';
default: return 'badge bg-secondary';
}
},
updateStatus(statusId, decision) { updateStatus(statusId, decision) {
console.log('statusId received:', statusId); // Add this for immediate inspection
if (!statusId) { if (!statusId) {
console.error("Invalid statusId passed to updateStatus."); console.error("Invalid statusId passed to updateStatus.");
alert("Error: Invalid request ID.");
return; return;
} }
@ -135,29 +422,43 @@
body: JSON.stringify({ statusId: statusId, decision: decision }) body: JSON.stringify({ statusId: statusId, decision: decision })
}) })
.then(res => { .then(res => {
if (!res.ok) throw new Error("Failed to update status"); if (!res.ok) {
return res.json().then(err => { throw new Error(err.message || "Failed to update status"); });
}
return res.json(); return res.json();
}) })
.then(() => { .then(() => {
this.loadData(); // Refresh table alert(`Request ${decision.toLowerCase()} successfully.`);
this.loadData();
}) })
.catch(err => { .catch(err => {
console.error("Error updating status:", err); console.error("Error updating status:", err);
alert("Error: " + err.message);
}); });
}, },
viewOtData(statusId) { viewOtData(statusId) {
// Navigate to another page with the statusId in query string sessionStorage.setItem('approvalSelectedMonth', this.selectedMonth);
sessionStorage.setItem('approvalSelectedYear', this.selectedYear);
window.location.href = `/OTcalculate/ApprovalDashboard/OtReview?statusId=${statusId}`; window.location.href = `/OTcalculate/ApprovalDashboard/OtReview?statusId=${statusId}`;
},
sortBy(column) {
if (this.sortByColumn === column) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortByColumn = column;
this.sortDirection = 'asc';
}
this.currentPage = 1;
} }
}, },
mounted() { mounted() {
this.loadData(); this.loadData();
},
beforeUnmount() {
sessionStorage.setItem('approvalSelectedMonth', this.selectedMonth);
sessionStorage.setItem('approvalSelectedYear', this.selectedYear);
} }
}); });
app.mount('#app'); app.mount('#app');
</script> </script>

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,6 @@
@{ @{
ViewBag.Title = "User Settings"; ViewBag.Title = "User Settings";
Layout = "~/Views/Shared/_Layout.cshtml"; Layout = "~/Views/Shared/_Layout.cshtml";
} }
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@ -15,7 +12,6 @@
background-color: white; background-color: white;
} }
#ApprovalFlowTable th, #ApprovalFlowTable th,
#ApprovalFlowTable td { #ApprovalFlowTable td {
background-color: white !important; /* Ensure no transparency from Bootstrap */ background-color: white !important; /* Ensure no transparency from Bootstrap */
@ -157,7 +153,7 @@
<th>Full Name</th> <th>Full Name</th>
<th>Department</th> <th>Department</th>
<th>Current State</th> <th>Current State</th>
<th>Current Flow</th> <th>Approval Flow</th>
<th class="text-center">Select</th> <th class="text-center">Select</th>
</tr> </tr>
</thead> </thead>
@ -241,17 +237,14 @@
<tr v-if="approvalFlowList.length === 0"> <tr v-if="approvalFlowList.length === 0">
<td colspan="3" class="text-center text-muted">No approval flows found.</td> <td colspan="3" class="text-center text-muted">No approval flows found.</td>
</tr> </tr>
<tr v-for="(flow, index) in approvalFlowList" :key="flow.approvalId"> <tr v-for="(flow, index) in approvalFlowList" :key="flow.approvalFlowId">
<td>{{ index + 1 }}</td> <td>{{ index + 1 }}</td>
<td>{{ flow.approvalName }}</td> <td>{{ flow.approvalName }}</td>
<td> <td>
<!-- Edit Button with Pencil Icon -->
<button class="btn btn-sm btn-primary me-2" v-on:click="openEditModal(flow)" title="Edit"> <button class="btn btn-sm btn-primary me-2" v-on:click="openEditModal(flow)" title="Edit">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<button class="btn btn-sm btn-danger" v-on:click="deleteApprovalFlow(flow.approvalFlowId)" title="Delete">
<!-- Delete Button with Trash Icon -->
<button class="btn btn-sm btn-danger" v-on:click="deleteApprovalFlow(flow.approvalId)" title="Delete">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</td> </td>
@ -305,8 +298,8 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">HR</label> <label class="form-label">HR</label>
<select class="form-select" v-model="editFlow.hr"> <select class="form-select" v-model="editFlow.hr" required>
<option :value="null">--None--</option> <option disabled value="">--Select--</option>
<option v-for="user in allUsers" :value="user.id">{{ user.fullName }}</option> <option v-for="user in allUsers" :value="user.id">{{ user.fullName }}</option>
</select> </select>
</div> </div>
@ -696,14 +689,42 @@
}, },
async deleteApprovalFlow(approvalId) { async deleteApprovalFlow(approvalId) {
if (!approvalId) {
// This handles the "undefined" ID case more gracefully
console.error("No approval ID provided for deletion.");
alert("An error occurred: No approval flow selected for deletion.");
return;
}
if (!confirm("Are you sure you want to delete this approval flow?")) return; if (!confirm("Are you sure you want to delete this approval flow?")) return;
try { try {
const response = await fetch(`/OvertimeAPI/DeleteApprovalFlow/${approvalId}`, { method: "DELETE" }); const response = await fetch(`/OvertimeAPI/DeleteApprovalFlow/${approvalId}`, {
if (!response.ok) throw new Error("Failed to delete"); method: "DELETE"
alert("Approval flow deleted."); });
if (response.ok) {
alert("Approval flow deleted successfully.");
await this.fetchApprovalFlows(); await this.fetchApprovalFlows();
} else {
// Parse the error response from the server
const errorData = await response.json();
const errorMessage = errorData.message || "Failed to delete approval flow.";
// Display the alert, but DON'T re-throw or console.error if it's a specific bad request
// Only log to console for unexpected server errors (e.g., 500 status codes)
if (response.status === 400) { // Check for Bad Request specifically
alert(`Error: ${errorMessage}`);
} else {
// For other errors (e.g., 500 Internal Server Error), still log to console
console.error("Error deleting flow:", errorMessage);
alert(`Error: ${errorMessage}`);
}
}
} catch (error) { } catch (error) {
console.error("Error deleting flow:", error); // This catch block handles network errors or errors that prevent a valid response
console.error("An unexpected error occurred during deletion:", error);
alert(`An unexpected error occurred: ${error.message || "Please try again."}`);
} }
}, },

View File

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

View File

@ -1,5 +1,5 @@
@{ @{
ViewData["Title"] = "Rate Update"; ViewData["Title"] = "Salary Update";
Layout = "~/Views/Shared/_Layout.cshtml"; Layout = "~/Views/Shared/_Layout.cshtml";
} }
@ -13,7 +13,7 @@
<h1 class="font-light text-white"> <h1 class="font-light text-white">
<i class="mdi mdi-currency-usd"></i> <i class="mdi mdi-currency-usd"></i>
</h1> </h1>
<h6 class="text-white">Rate</h6> <h6 class="text-white">Salary</h6>
</div> </div>
</a> </a>
</div> </div>
@ -51,15 +51,15 @@
<div class="card m-1"> <div class="card m-1">
<form v-on:submit.prevent="updateRates" data-aos="fade-right"> <form v-on:submit.prevent="updateRates" data-aos="fade-right">
<div class="card-header bg-white text-center"> <div class="card-header bg-white text-center">
<h3 class="rate-heading">UPDATE RATE</h3> <h3 class="rate-heading">UPDATE SALARY</h3>
</div> </div>
@* Enter Rate *@ @* Enter Rate *@
<div class="d-flex justify-content-center align-items-center"> <div class="d-flex justify-content-center align-items-center">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<label for="rate" class="mb-0">Rate</label> <label for="rate" class="mb-0">Salary</label>
<input type="number" id="rate" class="form-control text-center" v-model="rate" placeholder="Enter new rate" step="0.01"> <input type="number" id="rate" class="form-control text-center" v-model="rate" placeholder="Enter new salary" step="0.01">
<button type="button" class="btn btn-danger me-2" v-on:click="clearForm">Clear</button> <button type="button" class="btn btn-danger me-2" v-on:click="clearForm">Clear</button>
<button type="submit" class="btn btn-success col-4">Update Rates</button> <button type="submit" class="btn btn-success col-4">Update Salaries</button>
</div> </div>
</div> </div>
<div class="card-body"> <div class="card-body">
@ -68,7 +68,7 @@
<tr> <tr>
<th>Full Name</th> <th>Full Name</th>
<th>Department</th> <th>Department</th>
<th>Current Rate</th> <th>Current Salary</th>
<th class="text-center">Select</th> <th class="text-center">Select</th>
</tr> </tr>
</thead> </thead>
@ -76,7 +76,7 @@
</div> </div>
<div class="d-flex align-items-center justify-content-end gap-2 my-3"> <div class="d-flex align-items-center justify-content-end gap-2 my-3">
<button type="button" class="btn btn-danger" v-on:click="clearForm">Clear</button> <button type="button" class="btn btn-danger" v-on:click="clearForm">Clear</button>
<button type="submit" class="btn btn-success">Update Rates</button> <button type="submit" class="btn btn-success">Update Salaries</button>
</div> </div>
</form> </form>
@ -110,7 +110,7 @@
async fetchRates() { async fetchRates() {
try { try {
const response = await fetch("/OvertimeAPI/GetUserRates", { method: "POST", headers: { "Content-Type": "application/json" }}); const response = await fetch("/OvertimeAPI/GetUserRates", { method: "POST", headers: { "Content-Type": "application/json" }});
if (!response.ok) throw new Error("Failed to fetch rates"); if (!response.ok) throw new Error("Failed to fetch salaries");
const usersWithRates = await response.json(); const usersWithRates = await response.json();
this.userList = this.userList.map(user => { this.userList = this.userList.map(user => {
const userRate = usersWithRates.find(rate => rate.userId === user.id); const userRate = usersWithRates.find(rate => rate.userId === user.id);
@ -118,7 +118,7 @@
}); });
this.initiateTable(); this.initiateTable();
} catch (error) { } catch (error) {
console.error("Error fetching rates:", error); console.error("Error fetching salaries:", error);
} }
}, },
async updateRates() { async updateRates() {
@ -129,7 +129,7 @@
} }
let rateValue = parseFloat(this.rate); let rateValue = parseFloat(this.rate);
if (isNaN(rateValue)) { if (isNaN(rateValue)) {
alert("Please enter a valid rate."); alert("Please enter a valid salary.");
return; return;
} }
const payload = this.selectedRates.map(userId => ({ UserId: userId, RateValue: rateValue.toFixed(2) })); const payload = this.selectedRates.map(userId => ({ UserId: userId, RateValue: rateValue.toFixed(2) }));
@ -139,16 +139,16 @@
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
if (response.ok) { if (response.ok) {
alert("Rates updated successfully!"); alert("Salaries updated successfully!");
this.selectedRates = []; this.selectedRates = [];
this.rate = null; this.rate = null;
await this.fetchRates(); await this.fetchRates();
} else { } else {
alert("Failed to update rates. Please try again."); alert("Failed to update salaries. Please try again.");
} }
} catch (error) { } catch (error) {
console.error("Error updating rates:", error); console.error("Error updating salaries:", error);
alert("An error occurred while updating rates."); alert("An error occurred while updating salaries.");
} }
}, },
async fetchUser() { async fetchUser() {
@ -182,7 +182,7 @@
"data": "departmentName" "data": "departmentName"
}, },
{ {
"title": "Current Rate", "title": "Current Salary",
"data": "rateValue", "data": "rateValue",
"render": data => data ? parseFloat(data).toFixed(2) : 'N/A' "render": data => data ? parseFloat(data).toFixed(2) : 'N/A'
}, },

View File

@ -13,7 +13,7 @@
<h1 class="font-light text-white"> <h1 class="font-light text-white">
<i class="mdi mdi-currency-usd"></i> <i class="mdi mdi-currency-usd"></i>
</h1> </h1>
<h6 class="text-white">Rate</h6> <h6 class="text-white">Salary</h6>
</div> </div>
</a> </a>
</div> </div>
@ -68,7 +68,7 @@
<div class="tab-content" id="myTabContent"> <div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="home" role="tabpanel" aria-labelledby="home-tab"> <div class="tab-pane fade show active" id="home" role="tabpanel" aria-labelledby="home-tab">
<div class="card-header text-center" style="background-color: white;"> <div class="card-header text-center" style="background-color: white;">
<label class="date-heading text-center">Rate Latest Update: {{ rateUpdateDate || 'N/A' }}</label> <label class="date-heading text-center">Salary Latest Update: {{ rateUpdateDate || 'N/A' }}</label>
</div> </div>
</div> </div>
</div> </div>
@ -142,6 +142,7 @@
}, },
mounted() { mounted() {
this.fetchUpdateDates(); this.fetchUpdateDates();
this.checkIncompleteSettings(); // Call the new method on mount
}, },
methods: { methods: {
async fetchUpdateDates() { async fetchUpdateDates() {
@ -158,9 +159,29 @@
this.regionUpdateDate = data.regionUpdateDate; this.regionUpdateDate = data.regionUpdateDate;
this.approvalFlowUpdateDate = data.approvalFlowUpdateDate; this.approvalFlowUpdateDate = data.approvalFlowUpdateDate;
} catch (error) { } catch (error) {
console.error(error); console.error("Error fetching update dates:", error);
} }
}, },
async checkIncompleteSettings() {
try {
const response = await fetch("/OvertimeAPI/CheckIncompleteUserSettings", {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) throw new Error("Failed to check incomplete user settings");
const data = await response.json();
if (data.hasIncompleteSettings) {
// Access the new property 'numberOfIncompleteUsers'
const numberOfStaff = data.numberOfIncompleteUsers;
let alertMessage = `Action Required!\n\nThere are ${numberOfStaff} staff with incomplete Rate / Flexi Hour / Approval Flow / State.`;
alert(alertMessage);
}
} catch (error) {
console.error("Error checking incomplete settings:", error);
alert("An error occurred while checking for incomplete user settings.");
}
}
}, },
}).mount("#app"); }).mount("#app");
}; };

View File

@ -4,18 +4,18 @@
} }
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
<div id="app" class="container mt-4 d-flex justify-content-center"> <div id="app" class="container mt-4 d-flex justify-content-center">
<div class="card shadow-sm" style="width: 1100px;"> <div class="card shadow-sm" style="width: 1100px;">
<div class="card-body"> <div class="card-body">
<div class="row"> <div class="row">
<div class="col-md-7"> <div class="col-md-7">
<!-- Overtime Date Input -->
<div class="mb-3"> <div class="mb-3">
<label class="form-label" for="dateInput">Date</label> <label class="form-label" for="dateInput">Date <span class="text-danger">*</span></label>
<input type="date" class="form-control" v-model="editForm.otDate" v-on:input="calculateOTAndBreak"> <input type="date" class="form-control" v-model="editForm.otDate" v-on:input="calculateOTAndBreak" required>
<small class="text-danger" v-if="validationErrors.otDate">{{ validationErrors.otDate }}</small>
</div> </div>
<!-- Office Hours Section -->
<h6 class="fw-bold">OFFICE HOURS</h6> <h6 class="fw-bold">OFFICE HOURS</h6>
<div class="d-flex gap-3 mb-3 align-items-end flex-wrap"> <div class="d-flex gap-3 mb-3 align-items-end flex-wrap">
<div style="flex: 1;"> <div style="flex: 1;">
@ -39,7 +39,6 @@
</div> </div>
</div> </div>
<!-- After Office Hours Section -->
<h6 class="fw-bold text-danger">AFTER OFFICE HOURS</h6> <h6 class="fw-bold text-danger">AFTER OFFICE HOURS</h6>
<div class="d-flex gap-3 mb-3 align-items-end flex-wrap"> <div class="d-flex gap-3 mb-3 align-items-end flex-wrap">
<div style="flex: 1;"> <div style="flex: 1;">
@ -63,28 +62,43 @@
</div> </div>
</div> </div>
<!-- Air Station Dropdown (only for PSTW AIR) -->
<div class="mb-3" v-if="isPSTWAIR"> <div class="mb-3" v-if="isPSTWAIR">
<label for="airstationDropdown">Air Station</label> <label for="airstationDropdown">Air Station <span class="text-danger">*</span></label>
<select id="airstationDropdown" class="form-control" v-model="editForm.stationId"> <select id="airstationDropdown" class="form-control select2" v-model="editForm.stationId" required>
<option value="" disabled selected>Select Station</option> <option value="" disabled selected>Select Station</option>
<option v-for="station in airstationList" :key="station.stationId" :value="station.stationId"> <option v-for="station in airstationList" :key="station.stationId" :value="station.stationId">
{{ station.stationName || 'Unnamed Station' }} {{ station.stationName || 'Unnamed Station' }}
</option> </option>
</select> </select>
<small class="text-danger">*Only for PSTW AIR</small> <small class="text-danger" v-if="validationErrors.stationId">{{ validationErrors.stationId }}</small>
<small class="text-muted">*Only for PSTW AIR</small>
</div>
<div class="mb-3" v-if="isPSTWMARINE">
<label for="marinestationDropdown">Marine Station <span class="text-danger">*</span></label>
<select id="marinestationDropdown" class="form-control select2" v-model="editForm.stationId" required>
<option value="" disabled selected>Select Station</option>
<option v-for="station in marinestationList" :key="station.stationId" :value="station.stationId">
{{ station.stationName || 'Unnamed Station' }}
</option>
</select>
<small class="text-danger" v-if="validationErrors.stationId">{{ validationErrors.stationId }}</small>
<small class="text-muted">*Only for PSTW MARINE</small>
</div> </div>
<!-- Work Description Input -->
<div class="mb-3"> <div class="mb-3">
<label for="otDescription">Work Brief Description</label> <label for="otDescription">Work Brief Description <span class="text-danger">*</span></label>
<textarea id="otDescription" class="form-control" v-model="editForm.otDescription" v-on:input="limitCharCount" placeholder="Describe the work done..."></textarea> <textarea id="otDescription" class="form-control" v-model="editForm.otDescription" v-on:input="limitCharCount" placeholder="Describe the work done..." required></textarea>
<small class="text-danger" v-if="validationErrors.otDescription">{{ validationErrors.otDescription }}</small>
<small class="text-muted">{{ charCount }} / 150 characters</small> <small class="text-muted">{{ charCount }} / 150 characters</small>
</div> </div>
</div> </div>
<!-- Overtime Hours and Break Section -->
<div class="col-md-5 mt-5"> <div class="col-md-5 mt-5">
<div class="mb-3 d-flex flex-column align-items-center">
<label for="userFlexiHourDisplay">Your Flexi Hour</label>
<input type="text" id="userFlexiHourDisplay" class="form-control text-center" :value="userFlexiHourDisplay" readonly style="width: 200px;">
</div>
<div class="mb-3 d-flex flex-column align-items-center"> <div class="mb-3 d-flex flex-column align-items-center">
<label for="detectedDayType">Day</label> <label for="detectedDayType">Day</label>
<input type="text" class="form-control text-center" v-model="editForm.otDays" readonly style="width: 200px;"> <input type="text" class="form-control text-center" v-model="editForm.otDays" readonly style="width: 200px;">
@ -102,20 +116,19 @@
</div> </div>
</div> </div>
<!-- Action Buttons -->
<div class="d-flex justify-content-end mt-3"> <div class="d-flex justify-content-end mt-3">
<button class="btn btn-danger" v-on:click="goBack">Cancel</button> <button class="btn btn-danger" v-on:click="goBack">Cancel</button>
<button class="btn btn-success ms-3" v-on:click="updateRecord">Update</button> <button class="btn btn-success ms-3" v-on:click="validateAndUpdate">Update</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
@section Scripts { @section Scripts {
@{ @{
await Html.RenderPartialAsync("_ValidationScriptsPartial"); await Html.RenderPartialAsync("_ValidationScriptsPartial");
} }
<script> <script>
const app = Vue.createApp({ const app = Vue.createApp({
data() { data() {
@ -135,12 +148,15 @@
userId: null, userId: null,
}, },
airstationList: [], airstationList: [],
marinestationList: [],
totalOTHours: "0 hr 0 min", totalOTHours: "0 hr 0 min",
totalBreakHours: "0 hr 0 min", totalBreakHours: "0 hr 0 min",
currentUser: null, currentUser: null,
isPSTWAIR: false, isPSTWAIR: false,
userState: null, // To store the user's state information isPSTWMARINE: false,
publicHolidays: [], // To store public holidays userState: null,
publicHolidays: [],
userFlexiHour: null,
breakOptions: Array.from({ length: 15 }, (_, i) => { breakOptions: Array.from({ length: 15 }, (_, i) => {
const totalMinutes = i * 30; const totalMinutes = i * 30;
const hours = Math.floor(totalMinutes / 60); const hours = Math.floor(totalMinutes / 60);
@ -153,32 +169,88 @@
return { label, value: totalMinutes }; return { label, value: totalMinutes };
}), }),
previousPage: document.referrer,
previousPage: document.referrer returnMonth: null, // Add this
returnYear: null, // Add this
validationErrors: {
otDate: "",
stationId: "",
otDescription: "",
timeEntries: ""
}
}; };
}, },
computed: { computed: {
charCount() { charCount() {
return this.editForm.otDescription.length; return this.editForm.otDescription.length;
},
userFlexiHourDisplay() {
return this.userFlexiHour ? this.userFlexiHour.flexiHour : "N/A";
} }
}, },
async mounted() { async mounted() {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const overtimeId = urlParams.get('overtimeId'); const overtimeId = urlParams.get('overtimeId');
// Capture month and year from URL parameters
this.returnMonth = urlParams.get('month');
this.returnYear = urlParams.get('year');
if (overtimeId) { if (overtimeId) {
await this.fetchOvertimeRecord(overtimeId); await this.fetchOvertimeRecord(overtimeId);
} }
await this.fetchUserAndRelatedData(); // Fetch user, state, and holidays await this.fetchUserAndRelatedData();
const fetchStationPromises = [];
if (this.isPSTWAIR) { if (this.isPSTWAIR) {
await this.fetchStations(); fetchStationPromises.push(this.fetchStations());
}
if (this.isPSTWMARINE) {
fetchStationPromises.push(this.fetchStationsMarine());
}
await Promise.all(fetchStationPromises);
this.$nextTick(() => {
this.initializeSelect2Dropdowns();
if (this.editForm.stationId) {
if (this.isPSTWAIR) {
$('#airstationDropdown').val(this.editForm.stationId).trigger('change.select2');
}
if (this.isPSTWMARINE) {
$('#marinestationDropdown').val(this.editForm.stationId).trigger('change.select2');
}
}
});
},
methods: {
initializeSelect2Dropdowns() {
// Destroy any existing Select2 instances to prevent issues on re-initialization
if ($('#airstationDropdown').data('select2')) {
$('#airstationDropdown').select2('destroy');
}
if ($('#marinestationDropdown').data('select2')) {
$('#marinestationDropdown').select2('destroy');
}
if (this.isPSTWAIR) {
$('#airstationDropdown').select2({
placeholder: "Select Station",
allowClear: true
}).on('change', (e) => {
this.editForm.stationId = $(e.currentTarget).val();
});
}
if (this.isPSTWMARINE) {
$('#marinestationDropdown').select2({
placeholder: "Select Station",
allowClear: true
}).on('change', (e) => {
this.editForm.stationId = $(e.currentTarget).val();
});
} }
}, },
methods: {
async fetchOvertimeRecord(id) { async fetchOvertimeRecord(id) {
try { try {
const res = await fetch(`/OvertimeAPI/GetOvertimeRecordById/${id}`); const res = await fetch(`/OvertimeAPI/GetOvertimeRecordById/${id}`);
@ -186,6 +258,7 @@
const data = await res.json(); const data = await res.json();
this.populateForm(data); this.populateForm(data);
} else { } else {
console.error("Failed to fetch overtime record.");
alert("Failed to fetch overtime record."); alert("Failed to fetch overtime record.");
} }
} catch (err) { } catch (err) {
@ -197,19 +270,27 @@
this.editForm = { this.editForm = {
...record, ...record,
otDate: record.otDate ? record.otDate.slice(0, 10) : "", otDate: record.otDate ? record.otDate.slice(0, 10) : "",
// We will auto-detect the day, so we don't need to pre-fill otDays for auto-detection
}; };
this.calculateOTAndBreak(); this.calculateOTAndBreak();
this.updateDayType(); // Initial detection after loading data this.updateDayType();
}, },
async fetchStations() { async fetchStations() {
try { try {
const response = await fetch(`/OvertimeAPI/GetStationsByDepartment`); const response = await fetch(`/OvertimeAPI/GetStationsByDepartmentAir`);
if (!response.ok) throw new Error("Failed to fetch stations"); if (!response.ok) throw new Error("Failed to fetch stations");
this.airstationList = await response.json(); this.airstationList = await response.json();
} catch (error) { } catch (error) {
console.error("Error fetching stations:", error); console.error("Error fetching air stations:", error);
}
},
async fetchStationsMarine() {
try {
const response = await fetch(`/OvertimeAPI/GetStationsByDepartmentMarine`);
if (!response.ok) throw new Error("Failed to fetch stations");
this.marinestationList = await response.json();
} catch (error) {
console.error("Error fetching marine stations:", error);
} }
}, },
@ -224,10 +305,13 @@
const isSuperAdmin = this.currentUser?.role?.includes("SuperAdmin"); const isSuperAdmin = this.currentUser?.role?.includes("SuperAdmin");
const isSystemAdmin = this.currentUser?.role?.includes("SystemAdmin"); const isSystemAdmin = this.currentUser?.role?.includes("SystemAdmin");
const isDepartmentTwo = this.currentUser?.department?.departmentId === 2; const isDepartmentTwo = this.currentUser?.department?.departmentId === 2;
const isDepartmentThree = this.currentUser?.department?.departmentId === 3;
this.isPSTWAIR = isSuperAdmin || isSystemAdmin || isDepartmentTwo; this.isPSTWAIR = isSuperAdmin || isSystemAdmin || isDepartmentTwo;
this.isPSTWMARINE = isSuperAdmin || isSystemAdmin || isDepartmentThree;
if (this.editForm.userId) { if (this.editForm.userId) {
await this.fetchUserStateAndHolidays(); await this.fetchUserStateAndHolidays();
await this.fetchUserFlexiHour();
} }
} else { } else {
console.error(`Failed to fetch user: ${response.statusText}`); console.error(`Failed to fetch user: ${response.statusText}`);
@ -246,10 +330,36 @@
const data = await response.json(); const data = await response.json();
this.userState = data.state; this.userState = data.state;
this.publicHolidays = data.publicHolidays; this.publicHolidays = data.publicHolidays;
this.updateDayType(); // Detect day type after loading data this.updateDayType();
} catch (error) { } catch (error) {
console.error("Error fetching user state and holidays:", error); console.error("Error fetching user state and holidays:", error);
this.editForm.otDays = "Weekday"; // Default if fetching fails this.editForm.otDays = "Weekday";
}
},
async fetchUserFlexiHour() {
try {
if (!this.editForm.userId) {
console.warn("Cannot fetch flexi hour: User ID is not available.");
this.userFlexiHour = null;
return;
}
const response = await fetch(`/OvertimeAPI/GetUserFlexiHour/${this.editForm.userId}`);
if (response.ok) {
const data = await response.json();
if (data && data.flexiHour) {
this.userFlexiHour = data.flexiHour;
} else {
console.warn("Flexi hour data is missing or malformed in API response.", data);
this.userFlexiHour = null;
}
} else {
console.error(`Failed to fetch user flexi hour: ${response.statusText}`);
this.userFlexiHour = null;
}
} catch (error) {
console.error("Error fetching user flexi hour:", error);
this.userFlexiHour = null;
} }
}, },
@ -284,9 +394,11 @@
totalBreakMinutes = totalBreakMinutes % 60; totalBreakMinutes = totalBreakMinutes % 60;
this.totalBreakHours = `${totalBreakHours} hr ${totalBreakMinutes} min`; this.totalBreakHours = `${totalBreakHours} hr ${totalBreakMinutes} min`;
this.updateDayType(); // Update day type when times or date change this.updateDayType();
}, },
// Update the updateTime method to include midnight validation
updateTime(fieldName) { updateTime(fieldName) {
// Round time first
if (fieldName === 'officeFrom') { if (fieldName === 'officeFrom') {
this.editForm.officeFrom = this.roundToNearest30(this.editForm.officeFrom); this.editForm.officeFrom = this.roundToNearest30(this.editForm.officeFrom);
} else if (fieldName === 'officeTo') { } else if (fieldName === 'officeTo') {
@ -296,32 +408,102 @@
} else if (fieldName === 'afterTo') { } else if (fieldName === 'afterTo') {
this.editForm.afterTo = this.roundToNearest30(this.editForm.afterTo); this.editForm.afterTo = this.roundToNearest30(this.editForm.afterTo);
} }
// Recalculate OT and break times after rounding, if necessary
// Validate time ranges
this.validateTimeRanges();
this.calculateOTAndBreak(); this.calculateOTAndBreak();
}, },
// Add new validation method
validateTimeRanges() {
const validateRange = (fromTime, toTime, label) => {
if (!fromTime || !toTime) return true;
const start = this.parseTime(fromTime);
const end = this.parseTime(toTime);
const minAllowedFromMinutes = 16 * 60 + 30; // 4:30 PM
const maxAllowedFromMinutes = 23 * 60 + 30; // 11:30 PM
const startMinutes = start.hours * 60 + start.minutes;
const endMinutes = end.hours * 60 + end.minutes;
if (endMinutes === 0) { // If 'To' is 00:00 (midnight)
if (fromTime === "00:00") {
alert(`Invalid ${label} Time: 'From' and 'To' cannot both be 00:00 (midnight).`);
return false;
}
if (startMinutes < minAllowedFromMinutes || startMinutes > maxAllowedFromMinutes) {
alert(`Invalid ${label} Time: If 'To' is 12:00 am (00:00), 'From' must start between 4:30 pm and 11:30 pm on the same day.`);
return false;
}
} else if (endMinutes <= startMinutes) {
alert(`Invalid ${label} Time: 'To' time must be later than 'From' time for durations within the same day.`);
return false;
}
return true;
};
// Validate office hours
if (this.editForm.officeFrom && this.editForm.officeTo) {
if (!validateRange(this.editForm.officeFrom, this.editForm.officeTo, 'Office Hour')) {
this.editForm.officeTo = '';
}
}
// Validate after hours
if (this.editForm.afterFrom && this.editForm.afterTo) {
if (!validateRange(this.editForm.afterFrom, this.editForm.afterTo, 'After Office Hour')) {
this.editForm.afterTo = '';
}
}
},
// Update the roundToNearest30 method to match OtRegister
roundToNearest30(timeStr) { roundToNearest30(timeStr) {
if (!timeStr) return timeStr; if (!timeStr) return timeStr;
const [hours, minutes] = timeStr.split(':').map(Number); const [hours, minutes] = timeStr.split(':').map(Number);
const roundedMinutes = minutes < 15 ? 0 : minutes < 45 ? 30 : 0; const totalMinutes = hours * 60 + minutes;
const adjustedHour = minutes < 45 ? hours : (hours + 1) % 24; const remainder = totalMinutes % 30;
return `${adjustedHour.toString().padStart(2, '0')}:${roundedMinutes.toString().padStart(2, '0')}`;
// If closer to the lower 30-min mark, round down. Otherwise, round up.
const roundedMinutes = remainder < 15 ? totalMinutes - remainder : totalMinutes + (30 - remainder);
const adjustedHour = Math.floor(roundedMinutes / 60) % 24; // Ensure hours wrap around 24
const adjustedMinute = roundedMinutes % 60;
return `${adjustedHour.toString().padStart(2, '0')}:${adjustedMinute.toString().padStart(2, '0')}`;
}, },
// Update calculateTimeDifference to handle midnight case
calculateTimeDifference(startTime, endTime, breakMinutes) { calculateTimeDifference(startTime, endTime, breakMinutes) {
if (!startTime || !endTime) { if (!startTime || !endTime) {
return { hours: 0, minutes: 0 }; return { hours: 0, minutes: 0 };
} }
const start = this.parseTime(startTime); const start = this.parseTime(startTime);
const end = this.parseTime(endTime); const end = this.parseTime(endTime);
let diffMinutes = (end.hours * 60 + end.minutes) - (start.hours * 60 + start.minutes);
if (diffMinutes < 0) { let diffMinutes;
diffMinutes += 24 * 60;
// If TO is 00:00 (midnight), calculate the duration until end of day (24:00)
if (end.hours === 0 && end.minutes === 0 && (start.hours > 0 || start.minutes > 0)) {
diffMinutes = (24 * 60) - (start.hours * 60 + start.minutes);
} else if (end.hours * 60 + end.minutes <= start.hours * 60 + start.minutes) {
// For all other cases where 'To' time is on or before 'From' time, it's invalid.
return { hours: 0, minutes: 0 };
} else {
// Standard calculation for times within the same 24-hour period on the same day.
diffMinutes = (end.hours * 60 + end.minutes) - (start.hours * 60 + start.minutes);
} }
diffMinutes -= breakMinutes || 0; diffMinutes -= breakMinutes || 0;
if (diffMinutes < 0) diffMinutes = 0; // Ensure total hours don't go negative if break is too long
const hours = Math.floor(diffMinutes / 60); const hours = Math.floor(diffMinutes / 60);
const minutes = diffMinutes % 60; const minutes = diffMinutes % 60;
return { hours, minutes }; return { hours, minutes };
}, },
parseTime(timeString) { parseTime(timeString) {
const [hours, minutes] = timeString.split(':').map(Number); const [hours, minutes] = timeString.split(':').map(Number);
return { hours, minutes }; return { hours, minutes };
@ -330,7 +512,7 @@
formatTime(timeString) { formatTime(timeString) {
if (!timeString) return null; if (!timeString) return null;
const [hours, minutes] = timeString.split(':'); const [hours, minutes] = timeString.split(':');
return `${hours.padStart(2, '0')}:${minutes.padStart(2, '0')}:00`; //HH:mm:ss format return `${hours.padStart(2, '0')}:${minutes.padStart(2, '0')}:00`;
}, },
handleDateChange() { handleDateChange() {
@ -345,7 +527,7 @@
} }
const selectedDateObj = new Date(this.editForm.otDate + "T00:00:00"); const selectedDateObj = new Date(this.editForm.otDate + "T00:00:00");
const dayOfWeek = selectedDateObj.getDay(); // 0 (Sunday) to 6 (Saturday) const dayOfWeek = selectedDateObj.getDay();
const year = selectedDateObj.getFullYear(); const year = selectedDateObj.getFullYear();
const month = selectedDateObj.getMonth() + 1; const month = selectedDateObj.getMonth() + 1;
const day = selectedDateObj.getDate(); const day = selectedDateObj.getDate();
@ -375,80 +557,165 @@
this.editForm.otDays = "Weekday"; this.editForm.otDays = "Weekday";
}, },
validateForm() {
// Reset validation errors
this.validationErrors = {
otDate: "",
stationId: "",
otDescription: "",
timeEntries: ""
};
let isValid = true;
let errorMessages = [];
// Validate date
if (!this.editForm.otDate) {
this.validationErrors.otDate = "Date is required.";
errorMessages.push("Date is required.");
isValid = false;
}
// Validate station (only if user is in PSTW AIR or MARINE)
if ((this.isPSTWAIR || this.isPSTWMARINE) && !this.editForm.stationId) {
this.validationErrors.stationId = "Station is required.";
errorMessages.push("Station is required.");
isValid = false;
}
// Validate work brief description
if (!this.editForm.otDescription || this.editForm.otDescription.trim() === "") {
this.validationErrors.otDescription = "Work brief description is required.";
errorMessages.push("Work brief description is required.");
isValid = false;
}
// Validate at least one time entry (either office hours or after hours)
const hasOfficeHours = this.editForm.officeFrom && this.editForm.officeTo;
const hasAfterHours = this.editForm.afterFrom && this.editForm.afterTo;
if (!hasOfficeHours && !hasAfterHours) {
this.validationErrors.timeEntries = "Please enter either Office Hours or After Hours.";
errorMessages.push("Please enter either Office Hours or After Hours.");
isValid = false;
} else {
// Validate office hours if provided
if (hasOfficeHours) {
const officeFrom = this.parseTime(this.editForm.officeFrom);
const officeTo = this.parseTime(this.editForm.officeTo);
const officeFromMinutes = officeFrom.hours * 60 + officeFrom.minutes;
const officeToMinutes = officeTo.hours * 60 + officeTo.minutes;
if (officeTo.hours === 0 && officeTo.minutes === 0) {
// Midnight case - FROM must be between 4:30 PM and 11:30 PM
const minAllowed = 16 * 60 + 30; // 4:30 PM
const maxAllowed = 23 * 60 + 30; // 11:30 PM
if (officeFromMinutes < minAllowed || officeFromMinutes > maxAllowed) {
this.validationErrors.timeEntries = "Invalid Office Time: If 'To' is 00:00, 'From' must be between 4:30 PM and 11:30 PM.";
errorMessages.push("Invalid Office Time: If 'To' is 00:00, 'From' must be between 4:30 PM and 11:30 PM.");
isValid = false;
}
} else if (officeToMinutes <= officeFromMinutes) {
this.validationErrors.timeEntries = "Invalid Office Time: 'To' time must be later than 'From' time.";
errorMessages.push("Invalid Office Time: 'To' time must be later than 'From' time.");
isValid = false;
}
}
// Validate after hours if provided
if (hasAfterHours) {
const afterFrom = this.parseTime(this.editForm.afterFrom);
const afterTo = this.parseTime(this.editForm.afterTo);
const afterFromMinutes = afterFrom.hours * 60 + afterFrom.minutes;
const afterToMinutes = afterTo.hours * 60 + afterTo.minutes;
if (afterTo.hours === 0 && afterTo.minutes === 0) {
// Midnight case - FROM must be between 4:30 PM and 11:30 PM
const minAllowed = 16 * 60 + 30; // 4:30 PM
const maxAllowed = 23 * 60 + 30; // 11:30 PM
if (afterFromMinutes < minAllowed || afterFromMinutes > maxAllowed) {
this.validationErrors.timeEntries = "Invalid After Hours Time: If 'To' is 00:00, 'From' must be between 4:30 PM and 11:30 PM.";
errorMessages.push("Invalid After Hours Time: If 'To' is 00:00, 'From' must be between 4:30 PM and 11:30 PM.");
isValid = false;
}
} else if (afterToMinutes <= afterFromMinutes) {
this.validationErrors.timeEntries = "Invalid After Hours Time: 'To' time must be later than 'From' time.";
errorMessages.push("Invalid After Hours Time: 'To' time must be later than 'From' time.");
isValid = false;
}
}
}
// Display alert if not valid
if (!isValid) {
alert("Please correct the following issues:\n\n" + errorMessages.join("\n"));
}
return isValid;
},
validateAndUpdate() {
if (this.validateForm()) {
this.updateRecord();
}
},
async updateRecord() { async updateRecord() {
if (this.editForm.officeFrom && !this.editForm.officeTo) { const data = {
alert("Please enter a 'To' time for Office Hours."); OvertimeId: this.editForm.overtimeId,
return; OtDate: this.editForm.otDate,
} StationId: this.editForm.stationId || null,
if (this.editForm.officeTo && !this.editForm.officeFrom) { OtDescription: this.editForm.otDescription || "",
alert("Please enter a 'From' time for Office Hours."); OtDays: this.editForm.otDays,
return; OfficeFrom: this.editForm.officeFrom || null,
} OfficeTo: this.editForm.officeTo || null,
if (this.editForm.afterFrom && !this.editForm.afterTo) { OfficeBreak: this.editForm.officeBreak || 0,
alert("Please enter a 'To' time for After Hours."); AfterFrom: this.editForm.afterFrom || null,
return; AfterTo: this.editForm.afterTo || null,
} AfterBreak: this.editForm.afterBreak || 0,
if (this.editForm.afterTo && !this.editForm.afterFrom) { UserId: this.currentUser?.id
alert("Please enter a 'From' time for After Hours."); };
return;
}
// Check if both Office and After hours are empty
if ((!this.editForm.officeFrom || !this.editForm.officeTo) &&
(!this.editForm.afterFrom || !this.editForm.afterTo)) {
alert("Please enter either Office Hours or After Hours.");
return;
}
// Existing validation for "From" < "To" logic
if (this.editForm.officeFrom && this.editForm.officeTo) {
if (this.editForm.officeTo <= this.editForm.officeFrom) {
alert("Invalid Office Time: 'To' time must be later than 'From' time (same day only).");
return;
}
}
if (this.editForm.afterFrom && this.editForm.afterTo) {
if (this.editForm.afterTo <= this.editForm.afterFrom) {
alert("Invalid After Time: 'To' time must be later than 'From' time (same day only).");
return;
}
}
const formData = new FormData();
formData.append("OvertimeId", this.editForm.overtimeId);
formData.append("OtDate", this.editForm.otDate);
formData.append("StationId", this.editForm.stationId || "");
formData.append("OtDescription", this.editForm.otDescription || "");
formData.append("OtDays", this.editForm.otDays); // Use the auto-detected day type
formData.append("OfficeFrom", this.formatTime(this.editForm.officeFrom) || "");
formData.append("OfficeTo", this.formatTime(this.editForm.officeTo) || "");
formData.append("AfterFrom", this.formatTime(this.editForm.afterFrom) || "");
formData.append("AfterTo", this.formatTime(this.editForm.afterTo) || "");
formData.append("officeBreak", this.editForm.officeBreak || 0);
formData.append("afterBreak", this.editForm.afterBreak || 0);
formData.append("userId", this.currentUser?.id);
try { try {
const response = await fetch(`/OvertimeAPI/UpdateOvertimeRecord`, { const response = await fetch(`/OvertimeAPI/UpdateOvertimeRecord`, {
method: 'POST', method: 'POST',
body: formData headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
}); });
if (response.ok) { if (response.ok) {
alert("Overtime record updated successfully!"); const result = await response.json();
alert(result.message || "Overtime record updated successfully!");
window.location.href = this.previousPage; window.location.href = this.previousPage;
} else { } else {
alert("Failed to update overtime record."); let errorMessage = "Failed to update overtime record.";
try {
const errorData = await response.json();
errorMessage = errorData.message || errorMessage;
} catch (jsonError) {
errorMessage = await response.text();
if (!errorMessage) errorMessage = "An unexpected error occurred.";
}
alert(`Error: ${errorMessage} (Status: ${response.status})`);
} }
} catch (error) { } catch (error) {
console.error("Error updating record:", error); console.error("Error updating record:", error);
alert("An error occurred while updating the overtime record."); alert("An unexpected network error occurred while updating the overtime record. Please try again.");
} }
}, },
goBack() { goBack() {
// If we have stored month and year, use them to return to the specific view
if (this.returnMonth && this.returnYear) {
window.location.href = `/OTcalculate/Overtime/OtRecords?month=${this.returnMonth}&year=${this.returnYear}`;
} else {
// Fallback to previous page if month/year are not available
window.location.href = this.previousPage; window.location.href = this.previousPage;
}
}, },
clearOfficeHours() { clearOfficeHours() {
this.editForm.officeFrom = ""; this.editForm.officeFrom = "";
@ -462,7 +729,6 @@
this.editForm.afterBreak = 0; this.editForm.afterBreak = 0;
this.calculateOTAndBreak(); this.calculateOTAndBreak();
}, },
} }
}); });

View File

@ -87,8 +87,8 @@
<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</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</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 (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>
@ -111,12 +111,12 @@
<td>{{ formatDate(record.otDate) }}</td> <td>{{ formatDate(record.otDate) }}</td>
<td>{{ formatTime(record.officeFrom) }}</td> <td>{{ formatTime(record.officeFrom) }}</td>
<td>{{ formatTime(record.officeTo) }}</td> <td>{{ formatTime(record.officeTo) }}</td>
<td>{{ record.officeBreak }}</td> <td>{{ formatMinutesToHourMinute(record.officeBreak) }}</td>
<td>{{ formatTime(record.afterFrom) }}</td> <td>{{ formatTime(record.afterFrom) }}</td>
<td>{{ formatTime(record.afterTo) }}</td> <td>{{ formatTime(record.afterTo) }}</td>
<td>{{ record.afterBreak }}</td> <td>{{ formatMinutesToHourMinute(record.afterBreak) }}</td>
<td>{{ formatHourMinute(calcTotalTime(record)) }}</td> <td>{{ formatHourMinute(calcTotalTime(record)) }}</td>
<td>{{ calcBreakTotal(record) }}</td> <td>{{ formatMinutesToHourMinute(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>
@ -158,9 +158,9 @@
</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"><i class="bi bi-printer"></i> Print</button> <button class="btn btn-primary btn-sm" v-on:click="printPdf" :disabled="noRecordsFound"><i class="bi bi-printer"></i> Print</button>
<button class="btn btn-dark btn-sm" v-on:click="downloadPdf"><i class="bi bi-file-pdf"></i> Save</button> <button class="btn btn-dark btn-sm" v-on:click="downloadPdf" :disabled="noRecordsFound"><i class="bi bi-file-pdf"></i> Save</button>
<button class="btn btn-success btn-sm" v-on:click="downloadExcel(selectedMonth, selectedYear)"><i class="bi bi-file-earmark-excel"></i> Excel</button> <button class="btn btn-success btn-sm" v-on:click="downloadExcel(selectedMonth, selectedYear)" :disabled="noRecordsFound"><i class="bi bi-file-earmark-excel"></i> Excel</button>
<button class="btn btn-success btn-sm" :disabled="hasSubmitted" v-on:click="openSubmitModal"><i class="bi bi-send"></i>{{ hasSubmitted ? ' Submitted' : ' Submit' }}</button> <button class="btn btn-success btn-sm" :disabled="hasSubmitted" v-on:click="openSubmitModal"><i class="bi bi-send"></i>{{ hasSubmitted ? ' Submitted' : ' Submit' }}</button>
</div> </div>
<div class="modal fade" id="submitModal" tabindex="-1" aria-labelledby="submitModalLabel" aria-hidden="true"> <div class="modal fade" id="submitModal" tabindex="-1" aria-labelledby="submitModalLabel" aria-hidden="true">
@ -193,12 +193,27 @@
data() { data() {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1; const currentMonth = new Date().getMonth() + 1;
const urlParams = new URLSearchParams(window.location.search);
let initialMonth = urlParams.get('month');
let initialYear = urlParams.get('year');
if (!initialMonth && sessionStorage.getItem('lastSelectedMonth')) {
initialMonth = sessionStorage.getItem('lastSelectedMonth');
}
if (!initialYear && sessionStorage.getItem('lastSelectedYear')) {
initialYear = sessionStorage.getItem('lastSelectedYear');
}
initialMonth = initialMonth ? parseInt(initialMonth) : currentMonth;
initialYear = initialYear ? parseInt(initialYear) : currentYear;
return { return {
otRecords: [], otRecords: [],
userId: null, userId: null,
isPSTWAIR: false, isPSTWAIR: false,
selectedMonth: new Date().getMonth() + 1, selectedMonth: initialMonth, // Use initialMonth
selectedYear: currentYear, selectedYear: initialYear, // Use initialYear
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: {},
@ -208,10 +223,12 @@
}, },
watch: { watch: {
selectedMonth() { selectedMonth() {
this.getSubmissionStatus(); // Renamed method call this.getSubmissionStatus();
sessionStorage.setItem('lastSelectedMonth', this.selectedMonth);
}, },
selectedYear() { selectedYear() {
this.getSubmissionStatus(); // Renamed method call this.getSubmissionStatus();
sessionStorage.setItem('lastSelectedYear', this.selectedYear);
} }
}, },
computed: { computed: {
@ -220,6 +237,10 @@
.filter(r => new Date(r.otDate).getMonth() + 1 === this.selectedMonth && new Date(r.otDate).getFullYear() === this.selectedYear) .filter(r => new Date(r.otDate).getMonth() + 1 === this.selectedMonth && new Date(r.otDate).getFullYear() === this.selectedYear)
.sort((a, b) => new Date(a.otDate) - new Date(b.otDate)); .sort((a, b) => new Date(a.otDate) - new Date(b.otDate));
}, },
noRecordsFound() {
// This new computed property checks if filteredRecords is empty
return this.filteredRecords.length === 0;
},
totalHours() { totalHours() {
const total = this.filteredRecords.reduce((sum, r) => sum + this.calcTotalHours(r), 0); const total = this.filteredRecords.reduce((sum, r) => sum + this.calcTotalHours(r), 0);
const hours = Math.floor(total); const hours = Math.floor(total);
@ -244,6 +265,9 @@
async mounted() { async mounted() {
await this.initUserAndRecords(); await this.initUserAndRecords();
await this.getSubmissionStatus(); await this.getSubmissionStatus();
sessionStorage.setItem('lastSelectedMonth', this.selectedMonth);
sessionStorage.setItem('lastSelectedYear', this.selectedYear);
}, },
methods: { methods: {
async initUserAndRecords() { async initUserAndRecords() {
@ -267,11 +291,12 @@
const isSuperAdmin = this.currentUser?.role?.includes("SuperAdmin"); const isSuperAdmin = this.currentUser?.role?.includes("SuperAdmin");
const isSystemAdmin = this.currentUser?.role?.includes("SystemAdmin"); const isSystemAdmin = this.currentUser?.role?.includes("SystemAdmin");
const isDepartmentTwo = this.currentUser?.department?.departmentId === 2; const isDepartmentTwo = this.currentUser?.department?.departmentId === 2;
// ADD THIS LINE TO INCLUDE DEPARTMENT ID 3 (PSTWMARINE)
const isDepartmentThree = this.currentUser?.department?.departmentId === 3;
this.isPSTWAIR = isSuperAdmin || isSystemAdmin || isDepartmentTwo; this.isPSTWAIR = isSuperAdmin || isSystemAdmin || isDepartmentTwo || isDepartmentThree;
console.log("isPSTWAIR:", this.isPSTWAIR); console.log("isPSTWAIR:", this.isPSTWAIR);
} else { } else {
console.error(`Failed to fetch user: ${response.statusText}`); console.error(`Failed to fetch user: ${response.statusText}`);
} }
@ -317,11 +342,28 @@
const [hours, minutes] = t.split(':').map(Number); const [hours, minutes] = t.split(':').map(Number);
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
}, },
formatMinutesToHourMinute(minutes) {
if (!minutes || isNaN(minutes)) return "00:00";
const hrs = Math.floor(minutes / 60);
const mins = minutes % 60;
return `${hrs.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}`;
},
getTimeDiff(from, to) { getTimeDiff(from, to) {
if (!from || !to) return 0; if (!from || !to) return 0;
const [fh, fm] = from.split(":").map(Number); const [fh, fm] = from.split(":").map(Number);
const [th, tm] = to.split(":").map(Number); const [th, tm] = to.split(":").map(Number);
return ((th * 60 + tm) - (fh * 60 + fm)) / 60;
let totalMinutes = (th * 60 + tm) - (fh * 60 + fm);
// If the 'to' time is earlier than the 'from' time, it means it's an overnight span.
// Add 24 hours (1440 minutes) to account for the next day.
if (totalMinutes < 0) {
totalMinutes += 24 * 60; // Add 24 hours in minutes
}
return totalMinutes / 60; // Return total hours as a decimal
}, },
calcTotalTime(r) { calcTotalTime(r) {
const totalMinutes = this.calcTotalHours(r) * 60; const totalMinutes = this.calcTotalHours(r) * 60;
@ -351,7 +393,8 @@
}, },
editRecord(index) { editRecord(index) {
const record = this.filteredRecords[index]; const record = this.filteredRecords[index];
window.location.href = `/OTcalculate/Overtime/EditOvertime?overtimeId=${record.overtimeId}`; // Pass current month and year along with overtimeId
window.location.href = `/OTcalculate/Overtime/EditOvertime?overtimeId=${record.overtimeId}&month=${this.selectedMonth}&year=${this.selectedYear}`;
}, },
async deleteRecord(index) { async deleteRecord(index) {
const record = this.filteredRecords[index]; const record = this.filteredRecords[index];
@ -367,7 +410,7 @@
}, },
async printPdf() { async printPdf() {
try { try {
const response = await fetch(`/OvertimeAPI/GenerateOvertimePdf?month=${this.selectedMonth}&year=${this.selectedYear}`); const response = await fetch(`/OvertimeAPI/GetUserOvertimePdf/${this.userId}/${this.selectedMonth}/${this.selectedYear}`);
const blob = await response.blob(); const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob); const blobUrl = URL.createObjectURL(blob);
const printWindow = window.open(blobUrl, '_blank'); const printWindow = window.open(blobUrl, '_blank');
@ -379,27 +422,29 @@
console.error("Error generating PDF:", error); console.error("Error generating PDF:", error);
} }
}, },
async downloadPdf() { async downloadPdf() {
try { try {
const res = await fetch(`/OvertimeAPI/GenerateOvertimePdf?month=${this.selectedMonth}&year=${this.selectedYear}`); const url = `/OvertimeAPI/GetUserOvertimePdf/${this.userId}/${this.selectedMonth}/${this.selectedYear}`;
if (res.ok) { const response = await fetch(url);
const blob = await res.blob(); const blob = await response.blob();
const url = URL.createObjectURL(blob);
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = downloadUrl;
a.download = `OvertimeRecords_${this.selectedYear}_${this.selectedMonth}.pdf`; a.download = `Overtime_${this.selectedYear}_${this.selectedMonth}.pdf`;
document.body.appendChild(a);
a.click(); a.click();
URL.revokeObjectURL(url); window.URL.revokeObjectURL(downloadUrl);
} else { a.remove();
alert("Failed to generate PDF: " + await res.text()); } catch (error) {
} console.error("Error downloading PDF:", error);
} catch (err) { alert("Failed to download PDF. Please try again.");
console.error("PDF download error:", err);
alert("An error occurred while generating the PDF.");
} }
}, },
downloadExcel(month, year) { downloadExcel(month, year) {
window.open(`/OvertimeAPI/GenerateOvertimeExcel?month=${month}&year=${year}`, '_blank'); // Use the new API endpoint
window.open(`/OvertimeAPI/GenerateUserOvertimeExcel/${this.userId}/${month}/${year}`, '_blank');
}, },
openSubmitModal() { openSubmitModal() {
const modal = new bootstrap.Modal(document.getElementById('submitModal')); const modal = new bootstrap.Modal(document.getElementById('submitModal'));

View File

@ -1,4 +1,5 @@
@{ 
@{
ViewData["Title"] = "Register Overtime"; ViewData["Title"] = "Register Overtime";
Layout = "~/Views/Shared/_Layout.cshtml"; Layout = "~/Views/Shared/_Layout.cshtml";
} }
@ -11,10 +12,9 @@
<div class="row"> <div class="row">
<div class="col-md-7"> <div class="col-md-7">
<div class="mb-3"> <div class="mb-3">
<label class="form-label" for="dateInput">Date</label> <label class="form-label" for="dateInput">Date <span class="text-danger">*</span></label>
<input type="date" id="dateInput" class="form-control" v-model="selectedDate" <input type="date" id="dateInput" class="form-control" v-model="selectedDate"
v-on:input="handleDateChange"> v-on:input="handleDateChange">
</div> </div>
<h6 class="fw-bold">OFFICE HOURS</h6> <h6 class="fw-bold">OFFICE HOURS</h6>
@ -23,13 +23,11 @@
<label for="officeFrom">From</label> <label for="officeFrom">From</label>
<input type="time" id="officeFrom" class="form-control" v-model="officeFrom" <input type="time" id="officeFrom" class="form-control" v-model="officeFrom"
v-on:change="officeFrom = roundToNearest30(officeFrom); calculateOTAndBreak()"> v-on:change="officeFrom = roundToNearest30(officeFrom); calculateOTAndBreak()">
</div> </div>
<div class="col-4"> <div class="col-4">
<label for="officeTo">To</label> <label for="officeTo">To</label>
<input type="time" id="officeTo" class="form-control" v-model="officeTo" <input type="time" id="officeTo" class="form-control" v-model="officeTo"
v-on:change="officeTo = roundToNearest30(officeTo); calculateOTAndBreak()"> v-on:change="officeTo = roundToNearest30(officeTo); calculateOTAndBreak()">
</div> </div>
<div class="col-4"> <div class="col-4">
<label for="officeBreak">Break Hours (Minutes)</label> <label for="officeBreak">Break Hours (Minutes)</label>
@ -63,20 +61,29 @@
</div> </div>
</div> </div>
<div class="mb-3" v-if="isPSTWAIR"> <div class="mb-3" v-if="showAirDropdown">
<label for="airstationDropdown">Air Station</label> <label for="airStationDropdown">Air Station <span class="text-danger" v-if="requiresStation">*</span></label>
<select id="airstationDropdown" class="form-control" v-model="selectedAirStation"> <select id="airStationDropdown" class="form-control" style="width: 100%;" v-model="selectedAirStation">
<option value="" disabled selected>Select Station</option> <option value="">Select Air Station</option>
<option v-for="station in airstationList" :key="station.stationId" :value="station.stationId"> <option v-for="station in airStationList" :key="station.stationId" :value="station.stationId">
{{ station.stationName || 'Unnamed Station' }} {{ station.stationName || 'Unnamed Station' }}
</option> </option>
</select> </select>
<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">
<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>
<option v-for="station in marineStationList" :key="station.stationId" :value="station.stationId">
{{ station.stationName || 'Unnamed Station' }}
</option>
</select>
<small class="text-danger">*Only for PSTW MARINE</small>
</div>
<div class="mb-3"> <div class="mb-3">
<label for="otDescription">Work Brief Description</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"
v-model="otDescription" v-model="otDescription"
v-on:input="limitCharCount" v-on:input="limitCharCount"
@ -89,6 +96,12 @@
</div> </div>
<div class="col-md-5 mt-5"> <div class="col-md-5 mt-5">
<div class="mb-3 d-flex flex-column align-items-center">
<label for="flexiHourDisplay">Flexi Hour</label>
<input type="text" id="flexiHourDisplay" class="form-control text-center" v-model="userFlexiHour" readonly
style="width: 200px;">
</div>
<div class="mb-3 d-flex flex-column align-items-center"> <div class="mb-3 d-flex flex-column align-items-center">
<label for="detectedDayType">Day</label> <label for="detectedDayType">Day</label>
<input type="text" class="form-control text-center" v-model="detectedDayType" readonly <input type="text" class="form-control text-center" v-model="detectedDayType" readonly
@ -111,7 +124,10 @@
<div class="d-flex justify-content-end mt-3"> <div class="d-flex justify-content-end mt-3">
<button class="btn btn-danger" v-on:click="clearForm">Clear</button> <button class="btn btn-danger" v-on:click="clearForm">Clear</button>
<button class="btn btn-success ms-3" v-on:click="addOvertime">Save</button> <button class="btn btn-success ms-3" v-on:click="addOvertime" :disabled="!areUserSettingsComplete">Save</button>
</div>
<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.
</div> </div>
</div> </div>
</div> </div>
@ -132,17 +148,26 @@
afterFrom: "", afterFrom: "",
afterTo: "", afterTo: "",
afterBreak: 0, afterBreak: 0,
selectedAirStation: "", // New properties for separate station selections
airstationList: [], selectedAirStation: "", // Holds selected Air station ID
selectedMarineStation: "", // Holds selected Marine station ID
airStationList: [], // Stores stations for Air Department (DepartmentId = 2)
marineStationList: [], // Stores stations for Marine Department (DepartmentId = 3)
otDescription: "", otDescription: "",
detectedDayType: "", // To display the auto-detected day type userFlexiHour: "",
detectedDayType: "",
totalOTHours: "0 hr 0 min", totalOTHours: "0 hr 0 min",
totalBreakHours: "0 hr 0 min", totalBreakHours: "0 hr 0 min",
currentUser: null, currentUser: null,
userId: null, userId: null,
userState: null, // To store the user's state information userState: null,
publicHolidays: [], // To store public holidays for the user's state and year publicHolidays: [],
isPSTWAIR: false, // Keep user's actual department ID and admin flag for conditional rendering
userDepartmentId: null, // The department ID from the current user's profile
isUserAdmin: false, // True if the user is a SuperAdmin or SystemAdmin
departmentName: "", // This will be dynamic based on the user's main department for hints
areUserSettingsComplete: false,
breakOptions: Array.from({ length: 15 }, (_, i) => { breakOptions: Array.from({ length: 15 }, (_, i) => {
const totalMinutes = i * 30; const totalMinutes = i * 30;
const hours = Math.floor(totalMinutes / 60); const hours = Math.floor(totalMinutes / 60);
@ -160,23 +185,128 @@
computed: { computed: {
charCount() { charCount() {
return this.otDescription.length; return this.otDescription.length;
},
// Determines if the Air Station dropdown should be shown
showAirDropdown() {
return this.isUserAdmin || this.userDepartmentId === 2;
},
// Determines if the Marine Station dropdown should be shown
showMarineDropdown() {
return this.isUserAdmin || this.userDepartmentId === 3;
},
// This computed property selects the station ID to be sent to the backend.
// It assumes that if both are visible (for admins), only ONE should be selected.
// If both are selected by an admin, validation in addOvertime will catch it.
stationIdForSubmission() {
if (this.selectedAirStation) {
return parseInt(this.selectedAirStation);
}
if (this.selectedMarineStation) {
return parseInt(this.selectedMarineStation);
}
return null; // No station selected from either dropdown
},
// This indicates if *any* station selection is required based on visible dropdowns
requiresStation() {
return this.showAirDropdown || this.showMarineDropdown;
}
},
watch: {
// Watch for changes in airStationList to re-initialize Select2 for Air
airStationList: {
handler() {
this.$nextTick(() => {
const selectElement = $('#airStationDropdown');
if (selectElement.length) {
if (selectElement.data('select2')) {
selectElement.select2('destroy');
}
selectElement.select2({ theme: 'bootstrap4', placeholder: 'Select Air Station', allowClear: true });
// Set initial value if already selected
if (this.selectedAirStation) {
selectElement.val(this.selectedAirStation).trigger('change');
}
// Ensure event listener is set only once
selectElement.off('change.v-model-air').on('change.v-model-air', (event) => {
this.selectedAirStation = $(event.currentTarget).val();
});
}
});
},
deep: true
},
// Watch for changes in marineStationList to re-initialize Select2 for Marine
marineStationList: {
handler() {
this.$nextTick(() => {
const selectElement = $('#marineStationDropdown');
if (selectElement.length) {
if (selectElement.data('select2')) {
selectElement.select2('destroy');
}
selectElement.select2({ theme: 'bootstrap4', placeholder: 'Select Marine Station', allowClear: true });
// Set initial value if already selected
if (this.selectedMarineStation) {
selectElement.val(this.selectedMarineStation).trigger('change');
}
// Ensure event listener is set only once
selectElement.off('change.v-model-marine').on('change.v-model-marine', (event) => {
this.selectedMarineStation = $(event.currentTarget).val();
});
}
});
},
deep: true
},
// Keep selectedAirStation in sync with Select2 if it's already rendered
selectedAirStation(newVal) {
const selectElement = $('#airStationDropdown');
if (selectElement.length && selectElement.val() !== newVal) {
selectElement.val(newVal).trigger('change.select2');
}
},
// Keep selectedMarineStation in sync with Select2 if it's already rendered
selectedMarineStation(newVal) {
const selectElement = $('#marineStationDropdown');
if (selectElement.length && selectElement.val() !== newVal) {
selectElement.val(newVal).trigger('change.select2');
}
} }
}, },
async mounted() { async mounted() {
await this.fetchUser(); await this.fetchUser();
if (this.isPSTWAIR) { if (this.userId) {
this.fetchStations(); await this.checkUserSettings();
}
// Fetch stations for Air if the dropdown will be visible
if (this.showAirDropdown) {
await this.fetchStations(2, 'air'); // Department ID 2 for Air
}
// Fetch stations for Marine if the dropdown will be visible
if (this.showMarineDropdown) {
await this.fetchStations(3, 'marine'); // Department ID 3 for Marine
} }
}, },
methods: { methods: {
async fetchStations() { // Modified fetchStations to populate specific lists based on listType
async fetchStations(departmentId, listType) {
try { try {
const response = await fetch(`/OvertimeAPI/GetStationsByDepartment`); const response = await fetch(`/OvertimeAPI/GetStationsByDepartment?departmentId=${departmentId}`);
if (!response.ok) throw new Error("Failed to fetch stations"); if (!response.ok) throw new Error(`Failed to fetch ${listType} stations`);
const data = await response.json();
this.airstationList = await response.json(); if (listType === 'air') {
this.airStationList = data;
} else if (listType === 'marine') {
this.marineStationList = data;
}
} catch (error) { } catch (error) {
console.error("Error fetching stations:", error); console.error(`Error fetching ${listType} stations:`, error);
if (listType === 'air') {
this.airStationList = [];
} else if (listType === 'marine') {
this.marineStationList = [];
}
} }
}, },
async fetchUser() { async fetchUser() {
@ -187,29 +317,37 @@
this.currentUser = data?.userInfo || null; this.currentUser = data?.userInfo || null;
this.userId = this.currentUser?.id || null; this.userId = this.currentUser?.id || null;
console.log("Fetched User:", this.currentUser);
console.log("Dept ID:", this.currentUser?.department?.departmentId);
console.log("Roles:", this.currentUser?.role);
const isSuperAdmin = this.currentUser?.role?.includes("SuperAdmin"); const isSuperAdmin = this.currentUser?.role?.includes("SuperAdmin");
const isSystemAdmin = this.currentUser?.role?.includes("SystemAdmin"); const isSystemAdmin = this.currentUser?.role?.includes("SystemAdmin");
const isDepartmentTwo = this.currentUser?.department?.departmentId === 2; this.userDepartmentId = this.currentUser?.department?.departmentId; // Store user's actual department ID
this.isPSTWAIR = isSuperAdmin || isSystemAdmin || isDepartmentTwo; this.isUserAdmin = isSuperAdmin || isSystemAdmin; // Set the admin flag
console.log("isPSTWAIR:", this.isPSTWAIR);
if (this.isPSTWAIR) { // Set departmentName for the generic "Only for {{ departmentName }}" hint if not admin
this.fetchStations(); if (!this.isUserAdmin) {
if (this.userDepartmentId === 2) {
this.departmentName = "PSTW AIR";
} else if (this.userDepartmentId === 3) {
this.departmentName = "PSTW MARINE";
} else {
this.departmentName = "";
}
} else {
this.departmentName = ""; // Admins see both, so this specific hint is removed for them
} }
// Fetch user's state and public holidays after fetching user info console.log("Fetched User:", this.currentUser);
console.log("User Dept ID:", this.userDepartmentId);
console.log("Roles:", this.currentUser?.role);
console.log("isUserAdmin:", this.isUserAdmin);
if (this.userId) { if (this.userId) {
await this.fetchUserStateAndHolidays(); await this.fetchUserStateAndHolidays();
if (this.userState) { if (this.userState) {
this.statusId = this.userState.defaultStatusId; this.statusId = this.userState.defaultStatusId;
this.userFlexiHour = this.userState?.flexiHour || "N/A";
} }
} }
} else { } else {
console.error(`Failed to fetch user: ${response.statusText}`); console.error(`Failed to fetch user: ${response.statusText}`);
} }
@ -217,6 +355,24 @@
console.error("Error fetching user:", error); console.error("Error fetching user:", error);
} }
}, },
async checkUserSettings() {
try {
const response = await fetch(`/OvertimeAPI/CheckUserSettings/${this.userId}`);
if (!response.ok) {
throw new Error(`Failed to check user settings: ${response.statusText}`);
}
const data = await response.json();
this.areUserSettingsComplete = data.isComplete;
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.");
}
} catch (error) {
console.error("Error checking user settings:", error);
alert("An error occurred while verifying your settings. Please try again or contact support.");
this.areUserSettingsComplete = false;
}
},
async fetchUserStateAndHolidays() { async fetchUserStateAndHolidays() {
try { try {
const response = await fetch(`/OvertimeAPI/GetUserStateAndHolidays/${this.userId}`); const response = await fetch(`/OvertimeAPI/GetUserStateAndHolidays/${this.userId}`);
@ -226,18 +382,25 @@
const data = await response.json(); const data = await response.json();
this.userState = data.state; this.userState = data.state;
this.publicHolidays = data.publicHolidays; this.publicHolidays = data.publicHolidays;
this.updateDayType(); // Initial detection after loading data this.updateDayType();
} catch (error) { } catch (error) {
console.error("Error fetching user state and holidays:", error); console.error("Error fetching user state and holidays:", error);
this.detectedDayType = "Weekday"; // Default if fetching fails this.detectedDayType = "Weekday";
} }
}, },
roundToNearest30(timeStr) { roundToNearest30(timeStr) {
if (!timeStr) return timeStr; if (!timeStr) return timeStr;
const [hours, minutes] = timeStr.split(':').map(Number); const [hours, minutes] = timeStr.split(':').map(Number);
const roundedMinutes = minutes < 15 ? 0 : minutes < 45 ? 30 : 0; const totalMinutes = hours * 60 + minutes;
const adjustedHour = minutes < 45 ? hours : (hours + 1) % 24; const remainder = totalMinutes % 30;
return `${adjustedHour.toString().padStart(2, '0')}:${roundedMinutes.toString().padStart(2, '0')}`;
// If closer to the lower 30-min mark, round down. Otherwise, round up.
const roundedMinutes = remainder < 15 ? totalMinutes - remainder : totalMinutes + (30 - remainder);
const adjustedHour = Math.floor(roundedMinutes / 60) % 24; // Ensure hours wrap around 24
const adjustedMinute = roundedMinutes % 60;
return `${adjustedHour.toString().padStart(2, '0')}:${adjustedMinute.toString().padStart(2, '0')}`;
}, },
limitCharCount(event) { limitCharCount(event) {
if (this.otDescription.length > 150) { if (this.otDescription.length > 150) {
@ -269,12 +432,21 @@
const start = this.parseTime(startTime); const start = this.parseTime(startTime);
const end = this.parseTime(endTime); const end = this.parseTime(endTime);
let diffMinutes = (end.hours * 60 + end.minutes) - (start.hours * 60 + start.minutes); let diffMinutes;
if (diffMinutes < 0) {
diffMinutes += 24 * 60; // If TO is 00:00 (midnight), calculate the duration until end of day (24:00)
if (end.hours === 0 && end.minutes === 0 && (start.hours > 0 || start.minutes > 0)) {
diffMinutes = (24 * 60) - (start.hours * 60 + start.minutes);
} else if (end.hours * 60 + end.minutes <= start.hours * 60 + start.minutes) {
// For all other cases where 'To' time is on or before 'From' time, it's invalid.
return { hours: 0, minutes: 0 };
} else {
// Standard calculation for times within the same 24-hour period on the same day.
diffMinutes = (end.hours * 60 + end.minutes) - (start.hours * 60 + start.minutes);
} }
diffMinutes -= breakMinutes || 0; diffMinutes -= breakMinutes || 0;
if (diffMinutes < 0) diffMinutes = 0; // Ensure total hours don't go negative if break is too long
const hours = Math.floor(diffMinutes / 60); const hours = Math.floor(diffMinutes / 60);
const minutes = diffMinutes % 60; const minutes = diffMinutes % 60;
@ -288,15 +460,37 @@
formatTime(timeString) { formatTime(timeString) {
if (!timeString) return null; if (!timeString) return null;
const [hours, minutes] = timeString.split(':'); const [hours, minutes] = timeString.split(':');
return `${hours.padStart(2, '0')}:${minutes.padStart(2, '0')}:00`; // HH:mm:ss format return `${hours.padStart(2, '0')}:${minutes.padStart(2, '0')}:00`;
}, },
handleDateChange() { handleDateChange() {
this.updateDayType(); this.updateDayType();
this.calculateOTAndBreak(); this.calculateOTAndBreak();
}, },
async addOvertime() { async addOvertime() {
if (this.isPSTWAIR && !this.selectedAirStation) { if (!this.areUserSettingsComplete) {
alert("Please fill in all required fields."); alert("Cannot save overtime: Your essential user settings are incomplete. Please contact IT or HR.");
return;
}
// --- Frontend Validation ---
if (!this.selectedDate) {
alert("Please select a date for the overtime.");
return;
}
if (!this.otDescription.trim()) {
alert("Please provide a brief description of the work done.");
return;
}
let stationIdToSubmit = this.stationIdForSubmission;
if (this.requiresStation && !stationIdToSubmit) {
alert("Please select a station from the available dropdown(s).");
return;
}
if (this.isUserAdmin && this.selectedAirStation && this.selectedMarineStation) {
alert("As an administrator, please select *either* an Air Station *or* a Marine Station, but not both for a single overtime entry.");
return; return;
} }
@ -306,59 +500,62 @@
return; return;
} }
// Validate office hours const hasOfficeHours = this.officeFrom && this.officeTo;
if (this.officeTo && !this.officeFrom) { const hasAfterHours = this.afterFrom && this.afterTo;
alert("Please fill in the 'From' time for office hours.");
return;
}
if (this.officeFrom && !this.officeTo) { if (!hasOfficeHours && !hasAfterHours) {
alert("Please fill in the 'To' time for office hours.");
return;
}
if (this.officeFrom && this.officeTo) {
if (this.officeTo <= this.officeFrom) {
alert("Invalid Office Hour Time: 'To' time must be later than 'From' time (same day only).");
return;
}
}
// Validate after hours
if (this.afterTo && !this.afterFrom) {
alert("Please fill in the 'From' time for after office hours.");
return;
}
if (this.afterFrom && !this.afterTo) {
alert("Please fill in the 'To' time for after office hours.");
return;
}
if (this.afterFrom && this.afterTo) {
if (this.afterTo <= this.afterFrom) {
alert("Invalid After Office Hour Time: 'To' time must be later than 'From' time (same day only).");
return;
}
}
// Require at least one: Office or After
if ((!this.officeFrom || !this.officeTo) && (!this.afterFrom || !this.afterTo)) {
alert("Please enter either Office Hours or After Office Hours."); alert("Please enter either Office Hours or After Office Hours.");
return; return;
} }
// Validate time ranges according to the new rule: TO 00:00 only if FROM is 4:30 PM - 11:30 PM
const validateTimeRangeForSubmission = (fromTime, toTime, label) => {
if (!fromTime || !toTime) return true; // Handled by outer checks
const start = this.parseTime(fromTime);
const end = this.parseTime(toTime);
const minAllowedFromMinutesForMidnightTo = 16 * 60 + 30; // 4:30 PM
const maxAllowedFromMinutesForMidnightTo = 23 * 60 + 30; // 11:30 PM
const startMinutes = start.hours * 60 + start.minutes;
if (end.hours === 0 && end.minutes === 0) { // If 'To' is 00:00 (midnight)
if (fromTime === "00:00") {
alert(`Invalid ${label} Time: 'From' and 'To' cannot both be 00:00 (midnight).`);
return false;
}
// This is the specific rule: if 'To' is 00:00, 'From' must be within 4:30 PM and 11:30 PM
if (startMinutes < minAllowedFromMinutesForMidnightTo || startMinutes > maxAllowedFromMinutesForMidnightTo) {
alert(`Invalid ${label} Time: If 'To' is 12:00 am (00:00), 'From' must start between 4:30 pm and 11:30 pm on the same day to be saved.`);
return false;
}
} else if (end.hours * 60 + end.minutes <= start.hours * 60 + start.minutes) {
alert(`Invalid ${label} Time: 'To' time must be later than 'From' time for durations within the same day.`);
return false;
}
return true;
};
if (hasOfficeHours && !validateTimeRangeForSubmission(this.officeFrom, this.officeTo, 'Office Hour')) {
return;
}
if (hasAfterHours && !validateTimeRangeForSubmission(this.afterFrom, this.afterTo, 'After Office Hour')) {
return;
}
// --- End Frontend Validation ---
const requestData = { const requestData = {
otDate: this.selectedDate, otDate: this.selectedDate,
officeFrom: this.officeFrom ? this.formatTime(this.officeFrom) : null, officeFrom: this.officeFrom ? this.formatTime(this.officeFrom) : null,
officeTo: this.officeTo ? this.formatTime(this.officeTo) : null, officeTo: this.officeTo ? this.formatTime(this.officeTo) : null,
officeBreak: this.officeBreak || null, // Make this optional officeBreak: this.officeBreak || null,
afterFrom: this.afterFrom ? this.formatTime(this.afterFrom) : null, // Set to null if empty afterFrom: this.afterFrom ? this.formatTime(this.afterFrom) : null,
afterTo: this.afterTo ? this.formatTime(this.afterTo) : null, // Set to null if empty afterTo: this.afterTo ? this.formatTime(this.afterTo) : null,
afterBreak: this.afterBreak || null, // Make this optional afterBreak: this.afterBreak || null,
stationId: this.isPSTWAIR ? parseInt(this.selectedAirStation) : null, stationId: stationIdToSubmit, // Use the selected station from either dropdown
otDescription: this.otDescription.trim().split(/\s+/).slice(0, 50).join(' '), otDescription: this.otDescription.trim().split(/\s+/).slice(0, 50).join(' '),
otDays: this.detectedDayType, // Use the auto-detected day type otDays: this.detectedDayType,
userId: this.userId, userId: this.userId,
statusId: this.statusId || null, statusId: this.statusId || null,
}; };
@ -380,6 +577,13 @@
const result = await response.json(); const result = await response.json();
alert(result.message); alert(result.message);
this.clearForm(); this.clearForm();
await this.fetchUserStateAndHolidays();
if (this.userState) {
this.statusId = this.userState.defaultStatusId;
this.userFlexiHour = this.userState?.flexiHour || "N/A";
}
} catch (error) { } catch (error) {
console.error("Error adding overtime:", error); console.error("Error adding overtime:", error);
alert(`Failed to save overtime. Error: ${error.message}`); alert(`Failed to save overtime. Error: ${error.message}`);
@ -392,29 +596,24 @@
return; return;
} }
// Force parsing at midnight local time
const selectedDateObj = new Date(this.selectedDate + "T00:00:00"); const selectedDateObj = new Date(this.selectedDate + "T00:00:00");
const dayOfWeek = selectedDateObj.getDay(); // 0 (Sunday) to 6 (Saturday) const dayOfWeek = selectedDateObj.getDay();
const year = selectedDateObj.getFullYear(); const year = selectedDateObj.getFullYear();
const month = selectedDateObj.getMonth() + 1; const month = selectedDateObj.getMonth() + 1;
const day = selectedDateObj.getDate(); const day = selectedDateObj.getDate();
const formattedDate = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`; const formattedDate = `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
// 1. Check if it's a Public Holiday
if (this.publicHolidays.some(holiday => holiday.date === formattedDate)) { if (this.publicHolidays.some(holiday => holiday.date === formattedDate)) {
this.detectedDayType = "Public Holiday"; this.detectedDayType = "Public Holiday";
return; return;
} }
// 2. Check if it's a Weekend according to user's weekendId
const weekendId = this.userState.weekendId; const weekendId = this.userState.weekendId;
const isWeekend = (() => { const isWeekend = (() => {
if (weekendId === 1) { if (weekendId === 1) {
// WeekendId 1: Friday and Saturday return dayOfWeek === 5 || dayOfWeek === 6; // Friday and Saturday
return dayOfWeek === 5 || dayOfWeek === 6;
} else if (weekendId === 2) { } else if (weekendId === 2) {
// WeekendId 2: Saturday and Sunday return dayOfWeek === 6 || dayOfWeek === 0; // Saturday and Sunday
return dayOfWeek === 6 || dayOfWeek === 0;
} else { } else {
return dayOfWeek === 0; // Default Sunday return dayOfWeek === 0; // Default Sunday
} }
@ -425,7 +624,6 @@
return; return;
} }
// 3. Otherwise, it's a normal Weekday
this.detectedDayType = "Weekday"; this.detectedDayType = "Weekday";
}, },
@ -437,8 +635,21 @@
this.afterFrom = ""; this.afterFrom = "";
this.afterTo = ""; this.afterTo = "";
this.afterBreak = 0; this.afterBreak = 0;
this.selectedAirStation = ""; this.selectedAirStation = ""; // Clear specific station selections
this.selectedMarineStation = ""; // Clear specific station selections
// Clear Select2 for both dropdowns if they exist
const airSelect = $('#airStationDropdown');
if (airSelect.length && airSelect.data('select2')) {
airSelect.val('').trigger('change.select2');
}
const marineSelect = $('#marineStationDropdown');
if (marineSelect.length && marineSelect.data('select2')) {
marineSelect.val('').trigger('change.select2');
}
this.otDescription = ""; this.otDescription = "";
this.userFlexiHour = "";
this.detectedDayType = ""; this.detectedDayType = "";
this.totalOTHours = "0 hr 0 min"; this.totalOTHours = "0 hr 0 min";
this.totalBreakHours = "0 hr 0 min"; this.totalBreakHours = "0 hr 0 min";

View File

@ -11,60 +11,293 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-top: 20px; margin-top: 20px;
} }
.filter-container {
background-color: #f8f9fa;
padding: 15px;
border-radius: 5px;
margin-bottom: 20px;
}
.filter-row {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-bottom: 10px;
}
.filter-group {
flex: 1;
min-width: 200px;
}
.history-entry {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 5px;
padding: 10px;
margin-bottom: 10px;
}
.history-entry strong {
color: #343a40;
}
.history-entry em {
color: #6c757d;
}
.history-entry ul {
list-style: none;
padding-left: 0;
margin-bottom: 0;
}
.history-entry li {
margin-bottom: 5px;
}
.sortable-header {
cursor: pointer;
position: relative;
padding-right: 20px;
}
.sortable-header:hover {
background-color: #f1f1f1;
}
.sortable-header::after {
content: "↕";
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
font-size: 12px;
opacity: 0.5;
}
.sortable-header.asc::after {
content: "↑";
opacity: 1;
}
.sortable-header.desc::after {
content: "↓";
opacity: 1;
}
.badge-status {
font-size: 0.85em;
padding: 4px 8px;
border-radius: 4px;
}
.badge-pending {
background-color: #ffc107;
color: #212529;
}
.badge-approved {
background-color: #28a745;
color: white;
}
.badge-rejected {
background-color: #dc3545;
color: white;
}
</style> </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="table-layer"> <div class="table-layer">
<div class="white-box"> <div class="white-box">
<!-- Simplified Filter Section -->
<div class="filter-container">
<div class="row mb-3">
<div class="col-md-6">
<h5>Filters</h5>
</div>
<div class="col-md-6 text-end">
<button class="btn btn-sm btn-outline-secondary" v-on:click="resetFilters">
<i class="fas fa-redo"></i> Reset Filters
</button>
</div>
</div>
<div class="filter-row">
<div class="filter-group">
<label class="form-label">Month/Year</label>
<select class="form-select form-select-sm" v-model="filters.monthYear">
<option value="">All Months/Years</option>
<option v-for="(option, index) in monthYearOptions" :key="index" :value="option.value">
{{ option.text }}
</option>
</select>
</div>
<div class="filter-group">
<label class="form-label">Status</label>
<select class="form-select form-select-sm" v-model="filters.status">
<option value="">All Statuses</option>
<option v-for="(option, index) in statusOptions" :key="index" :value="option.value">
{{ option.text }}
</option>
</select>
</div>
</div>
</div>
<!-- Table Section -->
<div class="table-container table-responsive"> <div class="table-container table-responsive">
<table id="otStatusTable" class="table table-bordered table-sm table-striped"> <table id="otStatusTable" class="table table-bordered table-sm table-striped">
<thead> <thead>
<tr> <tr>
<th>Month/Year</th> <th class="sortable-header"
<th>Submit Date</th> :class="{'asc': sort.field === 'monthYear' && sort.order === 'asc', 'desc': sort.field === 'monthYear' && sort.order === 'desc'}"
<th v-if="includeHou">HoU Status</th> v-on:click="sortBy('monthYear')">Month/Year</th>
<th v-if="includeHod">HoD Status</th> <th class="sortable-header"
<th v-if="includeManager">Manager Status</th> :class="{'asc': sort.field === 'submitDate' && sort.order === 'asc', 'desc': sort.field === 'submitDate' && sort.order === 'desc'}"
<th v-if="includeHr">HR Status</th> v-on:click="sortBy('submitDate')">Submit Date</th>
<th>Updated</th> <th v-if="includeHou" class="sortable-header"
:class="{'asc': sort.field === 'houStatus' && sort.order === 'asc', 'desc': sort.field === 'houStatus' && sort.order === 'desc'}"
v-on:click="sortBy('houStatus')">HoU Status</th>
<th v-if="includeHod" class="sortable-header"
:class="{'asc': sort.field === 'hodStatus' && sort.order === 'asc', 'desc': sort.field === 'hodStatus' && sort.order === 'desc'}"
v-on:click="sortBy('hodStatus')">HoD Status</th>
<th v-if="includeManager" class="sortable-header"
:class="{'asc': sort.field === 'managerStatus' && sort.order === 'asc', 'desc': sort.field === 'managerStatus' && sort.order === 'desc'}"
v-on:click="sortBy('managerStatus')">Manager Status</th>
<th v-if="includeHr" class="sortable-header"
:class="{'asc': sort.field === 'hrStatus' && sort.order === 'asc', 'desc': sort.field === 'hrStatus' && sort.order === 'desc'}"
v-on:click="sortBy('hrStatus')">HR Status</th>
<th>Edit History</th>
<th>File</th> <th>File</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-for="(item, index) in otRecords" :key="index"> <tr v-for="(item, index) in filteredRecords" :key="index">
<td>{{ formatMonthYear(item.month, item.year) }}</td> <td>{{ formatMonthYear(item.month, item.year) }}</td>
<td>{{ formatDate(item.submitDate) }}</td> <td>{{ formatDate(item.submitDate) }}</td>
<td v-if="includeHou">{{ item.houStatus ?? '-' }}</td> <td v-if="includeHou">
<td v-if="includeHod">{{ item.hodStatus ?? '-' }}</td> <span v-if="item.houStatus" :class="getStatusBadgeClass(item.houStatus)">
<td v-if="includeManager">{{ item.managerStatus ?? '-' }}</td> {{ item.houStatus }}
<td v-if="includeHr">{{ item.hrStatus ?? '-' }}</td> </span>
<td>{{ item.updated ? 'Yes' : 'No' }}</td> <span v-else>-</span>
</td>
<td v-if="includeHod">
<span v-if="item.hodStatus" :class="getStatusBadgeClass(item.hodStatus)">
{{ item.hodStatus }}
</span>
<span v-else>-</span>
</td>
<td v-if="includeManager">
<span v-if="item.managerStatus" :class="getStatusBadgeClass(item.managerStatus)">
{{ item.managerStatus }}
</span>
<span v-else>-</span>
</td>
<td v-if="includeHr">
<span v-if="item.hrStatus" :class="getStatusBadgeClass(item.hrStatus)">
{{ item.hrStatus }}
</span>
<span v-else>-</span>
</td>
<td>
<button v-if="item.updated" class="btn btn-sm btn-info" v-on:click="showEditHistory(item)">
<i class="fas fa-history"></i> View History
</button>
<span v-else>-</span>
</td>
<td> <td>
<button v-if="item.filePath" class="btn btn-sm btn-primary" v-on:click="previewFile(item.filePath)"> <button v-if="item.filePath" class="btn btn-sm btn-primary" v-on:click="previewFile(item.filePath)">
View <i class="fas fa-file-alt"></i> View
</button> </button>
<span v-else>-</span> <span v-else>-</span>
</td> </td>
</tr> </tr>
<tr v-if="otRecords.length === 0"> <tr v-if="filteredRecords.length === 0">
<td :colspan="columnCount" class="text-center">No records found.</td> <td :colspan="columnCount" class="text-center">No records found matching your criteria.</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
<!-- Pagination -->
<div class="row mt-3" v-if="filteredRecords.length > 0">
<div class="col-md-6">
<div class="dataTables_info">
Showing {{ pagination.startItem }} to {{ pagination.endItem }} of {{ filteredRecords.length }} entries
</div>
</div>
<div class="col-md-6">
<nav aria-label="Page navigation" class="float-end">
<ul class="pagination pagination-sm">
<li class="page-item" :class="{disabled: pagination.currentPage === 1}">
<a class="page-link" href="#" v-on:click.prevent="prevPage">&laquo; Previous</a>
</li>
<li class="page-item" v-for="page in pagination.totalPages" :key="page"
:class="{active: pagination.currentPage === page}">
<a class="page-link" href="#" v-on:click.prevent="goToPage(page)">{{ page }}</a>
</li>
<li class="page-item" :class="{disabled: pagination.currentPage === pagination.totalPages}">
<a class="page-link" href="#" v-on:click.prevent="nextPage">Next &raquo;</a>
</li>
</ul>
</nav>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- File Preview Modal -->
<div v-if="showPreview" class="modal-backdrop"> <div class="modal fade" id="filePreviewModal" tabindex="-1" aria-labelledby="filePreviewModalLabel" aria-hidden="true">
<div class="modal-dialog-box"> <div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-header" style="background-color: white;"> <div class="modal-content">
<h5 class="modal-title">File Preview</h5> <div class="modal-header">
<button type="button" class="btn-close" aria-label="Close Preview" v-on:click ="closePreview"></button> <h5 class="modal-title" id="filePreviewModalLabel">File Preview</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<iframe :src="previewUrl" width="100%" height="650px" style="border: none;"></iframe> <iframe :src="previewUrl" width="100%" height="650px" style="border: none;"></iframe>
</div> </div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Edit History Modal -->
<div class="modal fade" id="editHistoryModal" tabindex="-1" aria-labelledby="editHistoryModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editHistoryModalLabel">Edit History for {{ selectedRecord ? formatMonthYear(selectedRecord.month, selectedRecord.year) : '' }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div v-if="parsedHistory.length > 0">
<div v-for="(entry, historyIndex) in parsedHistory" :key="historyIndex" class="history-entry">
<p><strong>Edited by:</strong> {{ entry.approvalRole }}</p>
<p><strong>Date:</strong> {{ formatDate(entry.date) }}</p>
<p><strong>Changes:</strong></p>
<ul v-if="entry.changes && entry.changes.length > 0">
<li v-for="(change, changeIndex) in entry.changes" :key="changeIndex">
<strong>{{ change.field }}:</strong> Changed from " <em>{{ change.before }}</em> " to " <em>{{ change.after }}</em> "
</li>
</ul>
<p v-else class="text-muted fst-italic">No specific changes detailed for this approval step.</p>
</div>
</div>
<div v-else>
<p class="text-center text-muted">No edit history available for this record.</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -79,67 +312,458 @@
includeHod: false, includeHod: false,
includeManager: false, includeManager: false,
includeHr: false, includeHr: false,
showPreview: false, previewUrl: '',
previewUrl: '' selectedRecord: null,
parsedHistory: [],
historyModalInstance: null,
filePreviewModalInstance: null,
stations: [],
// Filtering data
filters: {
monthYear: '',
status: ''
},
// Sorting data
sort: {
field: 'submitDate',
order: 'desc'
},
// Pagination data
pagination: {
currentPage: 1,
itemsPerPage: 10,
totalPages: 1,
startItem: 1,
endItem: 10
}
}; };
}, },
computed: { computed: {
columnCount() { columnCount() {
let count = 3; // Month/Year, SubmitDate, Updated let count = 3; // Month/Year, SubmitDate, Edit History
if (this.includeHou) count++; if (this.includeHou) count++;
if (this.includeHod) count++; if (this.includeHod) count++;
if (this.includeManager) count++; if (this.includeManager) count++;
if (this.includeHr) count++; if (this.includeHr) count++;
return count + 1; // File column return count + 1; // File column
}
}, },
mounted() {
fetch('/OvertimeAPI/GetUserOtStatus') monthYearOptions() {
.then(res => res.json()) const uniqueMonths = [...new Set(this.otRecords.map(item =>
.then(data => { `${item.month.toString().padStart(2, '0')}/${item.year}`
this.includeHou = data.includeHou; ))];
this.includeHod = data.includeHod; return uniqueMonths.map(my => ({
this.includeManager = data.includeManager; value: my,
this.includeHr = data.includeHr; text: my
this.otRecords = (data.otStatuses || []).sort((a, b) => { })).sort((a, b) => {
// Sort in descending order of SubmitDate (latest first) // Sort by year then month
return new Date(b.submitDate) - new Date(a.submitDate); const [aMonth, aYear] = a.value.split('/').map(Number);
const [bMonth, bYear] = b.value.split('/').map(Number);
return bYear - aYear || bMonth - aMonth;
});
},
statusOptions() {
const options = [
{ value: 'Pending', text: 'Pending' },
{ value: 'Approved', text: 'Approved' },
{ value: 'Rejected', text: 'Rejected' }
];
// Add combined status options if multiple approval levels exist
if ((this.includeHou && this.includeHod) ||
(this.includeHou && this.includeManager) ||
(this.includeHod && this.includeManager)) {
options.push(
{ value: 'PartiallyApproved', text: 'Partially Approved' },
{ value: 'FullyApproved', text: 'Fully Approved' }
);
}
return options;
},
filteredRecords() {
let filtered = [...this.otRecords];
// Apply filters
if (this.filters.monthYear) {
const [month, year] = this.filters.monthYear.split('/').map(Number);
filtered = filtered.filter(item =>
item.month === month && item.year === year
);
}
if (this.filters.status) {
if (this.filters.status === 'PartiallyApproved') {
filtered = filtered.filter(item => {
const statuses = [];
if (this.includeHou) statuses.push(item.houStatus);
if (this.includeHod) statuses.push(item.hodStatus);
if (this.includeManager) statuses.push(item.managerStatus);
if (this.includeHr) statuses.push(item.hrStatus);
const approvedCount = statuses.filter(s => s === 'Approved').length;
const rejectedCount = statuses.filter(s => s === 'Rejected').length;
const pendingCount = statuses.filter(s => s === 'Pending' || !s).length;
return approvedCount > 0 && pendingCount > 0;
});
} else if (this.filters.status === 'FullyApproved') {
filtered = filtered.filter(item => {
const statuses = [];
if (this.includeHou) statuses.push(item.houStatus);
if (this.includeHod) statuses.push(item.hodStatus);
if (this.includeManager) statuses.push(item.managerStatus);
if (this.includeHr) statuses.push(item.hrStatus);
return statuses.every(s => s === 'Approved');
});
} else {
filtered = filtered.filter(item => {
if (this.includeHou && item.houStatus === this.filters.status) return true;
if (this.includeHod && item.hodStatus === this.filters.status) return true;
if (this.includeManager && item.managerStatus === this.filters.status) return true;
if (this.includeHr && item.hrStatus === this.filters.status) return true;
return false;
});
}
}
// Apply sorting
filtered.sort((a, b) => {
let aValue, bValue;
switch (this.sort.field) {
case 'monthYear':
aValue = new Date(a.year, a.month - 1);
bValue = new Date(b.year, b.month - 1);
break;
case 'submitDate':
aValue = new Date(a.submitDate);
bValue = new Date(b.submitDate);
break;
case 'houStatus':
aValue = a.houStatus || '';
bValue = b.houStatus || '';
break;
case 'hodStatus':
aValue = a.hodStatus || '';
bValue = b.hodStatus || '';
break;
case 'managerStatus':
aValue = a.managerStatus || '';
bValue = b.managerStatus || '';
break;
case 'hrStatus':
aValue = a.hrStatus || '';
bValue = b.hrStatus || '';
break;
default:
return 0;
}
if (aValue < bValue) return this.sort.order === 'asc' ? -1 : 1;
if (aValue > bValue) return this.sort.order === 'asc' ? 1 : -1;
return 0;
}); });
this.$nextTick(() => { // Update pagination
// Delay to ensure table is rendered this.pagination.totalPages = Math.ceil(filtered.length / this.pagination.itemsPerPage);
if ($.fn.dataTable.isDataTable('#otStatusTable')) { this.pagination.currentPage = Math.min(this.pagination.currentPage, this.pagination.totalPages || 1);
$('#otStatusTable').DataTable().destroy();
const startIndex = (this.pagination.currentPage - 1) * this.pagination.itemsPerPage;
const endIndex = startIndex + this.pagination.itemsPerPage;
this.pagination.startItem = filtered.length > 0 ? startIndex + 1 : 0;
this.pagination.endItem = Math.min(endIndex, filtered.length);
return filtered.slice(startIndex, endIndex);
} }
$('#otStatusTable').DataTable({ },
responsive: true,
pageLength: 10, mounted() {
order: [] Promise.all([
}); fetch('/OvertimeAPI/GetUserOtStatus')
}); .then(res => {
if (!res.ok) {
throw new Error('Failed to fetch OT status');
}
return res.json();
}) })
.catch(err => { .catch(error => {
console.error("Error fetching OT records:", err); console.error('Error fetching OT status:', error);
return {
includeHou: false,
includeHod: false,
includeManager: false,
includeHr: false,
otStatuses: [],
hasApprovalFlow: false
};
}),
fetch('/OvertimeAPI/GetAllStations').then(res => res.json())
])
.then(([otData, stationData]) => {
this.includeHou = otData.includeHou || false;
this.includeHod = otData.includeHod || false;
this.includeManager = otData.includeManager || false;
this.includeHr = otData.includeHr || false;
this.otRecords = (otData.otStatuses || []).map(item => ({
...item,
monthYear: `${item.month.toString().padStart(2, '0')}/${item.year}`
}));
if (otData.hasApprovalFlow === false) {
alert("Note: Your approval flow is not yet configured. Only basic information is shown.");
}
this.stations = stationData;
this.historyModalInstance = new bootstrap.Modal(document.getElementById('editHistoryModal'));
this.filePreviewModalInstance = new bootstrap.Modal(document.getElementById('filePreviewModal'));
})
.catch(error => {
console.error('Error initializing data:', error);
this.includeHou = false;
this.includeHod = false;
this.includeManager = false;
this.includeHr = false;
this.otRecords = [];
}); });
}, },
methods: { methods: {
formatDate(dateStr) { formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr); const date = new Date(dateStr);
return date.toLocaleDateString(); if (isNaN(date.getTime())) return '-';
return date.toLocaleDateString('en-MY', { year: 'numeric', month: '2-digit', day: '2-digit' });
}, },
formatMonthYear(month, year) { formatMonthYear(month, year) {
if (!month || !year) return '-'; if (!month || !year) return '-';
return `${month.toString().padStart(2, '0')}/${year}`; return `${month.toString().padStart(2, '0')}/${year}`;
}, },
getStatusBadgeClass(status) {
if (!status) return '';
const statusLower = status.toLowerCase();
if (statusLower.includes('approve')) return 'badge badge-status badge-approved';
if (statusLower.includes('reject')) return 'badge badge-status badge-rejected';
return 'badge badge-status badge-pending';
},
previewFile(path) { previewFile(path) {
this.previewUrl = '/' + path.replace(/^\/+/, ''); this.previewUrl = '/' + path.replace(/^\/+/, '');
this.showPreview = true; if (this.filePreviewModalInstance) {
document.body.style.overflow = 'hidden'; this.filePreviewModalInstance.show();
}
}, },
closePreview() {
this.previewUrl = ''; getStationNameById(stationId) {
this.showPreview = false; if (!stationId) return 'N/A';
document.body.style.overflow = ''; const station = this.stations.find(s => s.stationId === stationId);
return station ? station.stationName : `Unknown Station (ID: ${stationId})`;
},
formatMinutesToHours(minutes) {
if (minutes === null || minutes === undefined || isNaN(minutes)) {
return '';
}
if (minutes < 0) return 'Invalid (Negative)';
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
const formattedHours = String(hours).padStart(1, '0');
const formattedMinutes = String(remainingMinutes).padStart(2, '0');
return `${formattedHours}:${formattedMinutes}`;
},
getChanges(before, after) {
const changes = [];
if (!before && !after) return [];
if (!before && after) {
for (const key in after) {
if (Object.prototype.hasOwnProperty.call(after, key)) {
let displayValue = after[key];
if (key === 'StationId') {
displayValue = this.getStationNameById(after[key]);
} else if (['OfficeBreak', 'AfterBreak'].includes(key)) {
displayValue = this.formatMinutesToHours(after[key]);
}
changes.push({ field: key, before: 'N/A (New Record)', after: displayValue });
}
}
return changes;
}
if (before && !after) {
return [];
}
const allKeys = new Set([...Object.keys(before), ...Object.keys(after)]);
allKeys.forEach(key => {
const beforeValue = before[key];
const afterValue = after[key];
let formattedBefore = beforeValue;
let formattedAfter = afterValue;
const timeFields = ['OfficeFrom', 'OfficeTo', 'AfterFrom', 'AfterTo'];
if (timeFields.includes(key)) {
if (typeof beforeValue === 'string' && beforeValue.match(/^\d{2}:\d{2}(:\d{2})?$/)) {
formattedBefore = beforeValue.substring(0, 5);
}
if (typeof afterValue === 'string' && afterValue.match(/^\d{2}:\d{2}(:\d{2})?$/)) {
formattedAfter = afterValue.substring(0, 5);
}
if (formattedBefore === "00:00" || formattedBefore === "00:00:00") formattedBefore = "";
if (formattedAfter === "00:00" || formattedAfter === "00:00:00") formattedAfter = "";
if (formattedBefore === null) formattedBefore = "";
if (formattedAfter === null) formattedAfter = "";
}
if (key === 'StationId') {
formattedBefore = this.getStationNameById(beforeValue);
formattedAfter = this.getStationNameById(afterValue);
}
else if (['OfficeBreak', 'AfterBreak'].includes(key)) {
formattedBefore = this.formatMinutesToHours(beforeValue);
formattedAfter = this.formatMinutesToHours(afterValue);
}
else if (key.includes('Date')) {
formattedBefore = beforeValue ? this.formatDate(beforeValue) : '';
formattedAfter = afterValue ? this.formatDate(afterValue) : '';
}
formattedBefore = (formattedBefore === null || formattedBefore === undefined) ? '' : formattedBefore;
formattedAfter = (formattedAfter === null || formattedAfter === undefined) ? '' : formattedAfter;
if (String(formattedBefore) !== String(formattedAfter)) {
if (!['StatusId', 'Otstatus', 'UserId', 'OvertimeId'].includes(key)) {
changes.push({
field: key,
before: formattedBefore === '' ? 'Empty' : formattedBefore,
after: formattedAfter === '' ? 'Empty' : formattedAfter
});
}
}
});
return changes;
},
showEditHistory(record) {
this.selectedRecord = record;
this.parsedHistory = [];
const updateFields = ['houUpdate', 'hodUpdate', 'managerUpdate', 'hrUpdate'];
const approvalRoles = {
'houUpdate': 'HoU',
'hodUpdate': 'HoD',
'managerUpdate': 'Manager',
'hrUpdate': 'HR'
};
updateFields.forEach(field => {
if (record[field]) {
try {
const updateLogArray = JSON.parse(record[field]);
if (Array.isArray(updateLogArray)) {
updateLogArray.forEach(logEntry => {
let changesDetail = [];
if (logEntry.ChangeType === 'Edit') {
if (logEntry.BeforeEdit && logEntry.AfterEdit) {
changesDetail = this.getChanges(logEntry.BeforeEdit, logEntry.AfterEdit);
}
} else if (logEntry.ChangeType === 'Delete') {
if (logEntry.DeletedRecord) {
changesDetail.push({
field: 'Record Deletion',
before: `Overtime ID: ${logEntry.DeletedRecord.OvertimeId} (Date: ${this.formatDate(logEntry.DeletedRecord.OtDate)}, Station: ${this.getStationNameById(logEntry.DeletedRecord.StationId)}, Desc: ${logEntry.DeletedRecord.OtDescription ? logEntry.DeletedRecord.OtDescription.substring(0, 50) + '...' : 'N/A'})`,
after: 'Record Deleted'
});
} else {
changesDetail.push({ field: 'Record Deletion', before: 'Unknown Record', after: 'Record Deleted' });
}
}
this.parsedHistory.push({
approvedBy: logEntry.ApproverRole || 'N/A',
approvalRole: logEntry.ApproverRole || approvalRoles[field],
date: logEntry.UpdateTimestamp || new Date().toISOString(),
changes: changesDetail
});
});
}
} catch (e) {
console.error(`Error parsing JSON for ${field}:`, e, record[field]);
}
}
});
this.parsedHistory.sort((a, b) => new Date(b.date) - new Date(a.date));
if (this.historyModalInstance) {
this.historyModalInstance.show();
}
},
closeEditHistory() {
if (this.historyModalInstance) {
this.historyModalInstance.hide();
}
this.selectedRecord = null;
this.parsedHistory = [];
},
// Sorting methods
sortBy(field) {
if (this.sort.field === field) {
this.sort.order = this.sort.order === 'asc' ? 'desc' : 'asc';
} else {
this.sort.field = field;
this.sort.order = 'asc';
}
this.pagination.currentPage = 1; // Reset to first page when sorting changes
},
// Filter methods
resetFilters() {
this.filters = {
monthYear: '',
status: ''
};
this.pagination.currentPage = 1;
},
// Pagination methods
prevPage() {
if (this.pagination.currentPage > 1) {
this.pagination.currentPage--;
}
},
nextPage() {
if (this.pagination.currentPage < this.pagination.totalPages) {
this.pagination.currentPage++;
}
},
goToPage(page) {
if (page >= 1 && page <= this.pagination.totalPages) {
this.pagination.currentPage = page;
}
} }
} }
}); });

File diff suppressed because it is too large Load Diff

View File

@ -108,6 +108,7 @@ namespace PSTW_CentralSystem.DBContext
public DbSet<HrUserSettingModel> Hrusersetting { get; set; } public DbSet<HrUserSettingModel> Hrusersetting { get; set; }
public DbSet<FlexiHourModel> Flexihour { get; set; } public DbSet<FlexiHourModel> Flexihour { get; set; }
public DbSet<ApprovalFlowModel> Approvalflow { get; set; } public DbSet<ApprovalFlowModel> Approvalflow { get; set; }
public DbSet<StaffSignModel> Staffsign { get; set; }
} }
} }

View File

@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="ClosedXML" Version="0.104.2" /> <PackageReference Include="ClosedXML" Version="0.104.2" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" /> <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.11" /> <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="8.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.11" />
@ -31,6 +32,7 @@
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
<PackageReference Include="QuestPDF" Version="2025.4.0" /> <PackageReference Include="QuestPDF" Version="2025.4.0" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" /> <PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageReference Include="System.Drawing.Common" Version="9.0.5" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -8,6 +8,7 @@ using Serilog;
using QuestPDF; using QuestPDF;
using QuestPDF.Infrastructure; using QuestPDF.Infrastructure;
using PSTW_CentralSystem.Areas.OTcalculate.Services; using PSTW_CentralSystem.Areas.OTcalculate.Services;
using DocumentFormat.OpenXml.Office2016.Drawing.ChartDrawing;
internal class Program internal class Program
{ {
@ -21,8 +22,7 @@ internal class Program
// Add services to the container. // Add services to the container.
builder.Services.AddControllersWithViews(); builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages(); builder.Services.AddRazorPages();
builder.Services.AddScoped<OvertimePdfService>(); builder.Services.AddScoped<OvertimeExcel>();
builder.Services.AddTransient<OvertimeExcelService>();