updated
This commit is contained in:
parent
19a9ade3eb
commit
5efe8e13c4
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
namespace PSTW_CentralSystem.Areas.OTcalculate.Models
|
|
||||||
{
|
|
||||||
public class OtRecordsModel
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
28
Areas/OTcalculate/Models/StaffSignModel.cs
Normal file
28
Areas/OTcalculate/Models/StaffSignModel.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
2195
Areas/OTcalculate/Services/OvertimeExcel.cs
Normal file
2195
Areas/OTcalculate/Services/OvertimeExcel.cs
Normal file
File diff suppressed because it is too large
Load Diff
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1294
Areas/OTcalculate/Services/OvertimePDF.cs
Normal file
1294
Areas/OTcalculate/Services/OvertimePDF.cs
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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' ? '▲' : '▼' }}
|
||||||
<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' ? '▲' : '▼' }}
|
||||||
|
</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' ? '▲' : '▼' }}
|
||||||
|
</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">«</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">»</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
@ -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.");
|
});
|
||||||
await this.fetchApprovalFlows();
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert("Approval flow deleted successfully.");
|
||||||
|
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."}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
@{
|
|
||||||
ViewData["Title"] = "OT Approval";
|
|
||||||
Layout = "~/Views/Shared/_Layout.cshtml";
|
|
||||||
}
|
|
||||||
@ -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'
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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");
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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() {
|
||||||
@ -131,54 +144,113 @@
|
|||||||
afterBreak: 0,
|
afterBreak: 0,
|
||||||
stationId: "",
|
stationId: "",
|
||||||
otDescription: "",
|
otDescription: "",
|
||||||
otDays: "",
|
otDays: "",
|
||||||
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);
|
||||||
const minutes = totalMinutes % 60;
|
const minutes = totalMinutes % 60;
|
||||||
|
|
||||||
let label = '';
|
let label = '';
|
||||||
if (hours > 0) label += `${hours} hour${hours > 1 ? 's' : ''}`;
|
if (hours > 0) label += `${hours} hour${hours > 1 ? 's' : ''}`;
|
||||||
if (minutes > 0) label += `${label ? ' ' : ''}${minutes} min`;
|
if (minutes > 0) label += `${label ? ' ' : ''}${minutes} min`;
|
||||||
if (!label) label = '0 min';
|
if (!label) label = '0 min';
|
||||||
|
|
||||||
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();
|
||||||
if (this.isPSTWAIR) {
|
|
||||||
await this.fetchStations();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
|
const fetchStationPromises = [];
|
||||||
|
if (this.isPSTWAIR) {
|
||||||
|
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: {
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
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() {
|
||||||
window.location.href = this.previousPage;
|
// 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;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
clearOfficeHours() {
|
clearOfficeHours() {
|
||||||
this.editForm.officeFrom = "";
|
this.editForm.officeFrom = "";
|
||||||
@ -462,7 +729,6 @@
|
|||||||
this.editForm.afterBreak = 0;
|
this.editForm.afterBreak = 0;
|
||||||
this.calculateOTAndBreak();
|
this.calculateOTAndBreak();
|
||||||
},
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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,17 +111,17 @@
|
|||||||
<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>
|
||||||
<td class="wrap-text">
|
<td class="wrap-text">
|
||||||
<div class="description-preview" v-on:click ="toggleDescription(index)" :class="{ expanded: expandedDescriptions[index] }">
|
<div class="description-preview" v-on:click="toggleDescription(index)" :class="{ expanded: expandedDescriptions[index] }">
|
||||||
{{ record.otDescription }}
|
{{ record.otDescription }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@ -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">
|
||||||
@ -171,11 +171,11 @@
|
|||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<input type="file" class="form-control" v-on:change ="handleFileUpload">
|
<input type="file" class="form-control" v-on:change="handleFileUpload">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" v-on:click ="submitOvertime">Submit</button>
|
<button type="button" class="btn btn-primary" v-on:click="submitOvertime">Submit</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -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 a = document.createElement('a');
|
const downloadUrl = window.URL.createObjectURL(blob);
|
||||||
a.href = url;
|
const a = document.createElement('a');
|
||||||
a.download = `OvertimeRecords_${this.selectedYear}_${this.selectedMonth}.pdf`;
|
a.href = downloadUrl;
|
||||||
a.click();
|
a.download = `Overtime_${this.selectedYear}_${this.selectedMonth}.pdf`;
|
||||||
URL.revokeObjectURL(url);
|
document.body.appendChild(a);
|
||||||
} else {
|
a.click();
|
||||||
alert("Failed to generate PDF: " + await res.text());
|
window.URL.revokeObjectURL(downloadUrl);
|
||||||
}
|
a.remove();
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
console.error("PDF download error:", err);
|
console.error("Error downloading PDF:", error);
|
||||||
alert("An error occurred while generating the PDF.");
|
alert("Failed to download PDF. Please try again.");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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'));
|
||||||
@ -448,4 +493,4 @@
|
|||||||
});
|
});
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
@ -22,18 +22,16 @@
|
|||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<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>
|
||||||
<select id="officeBreak" class="form-control" v-model.number="officeBreak" v-on:change ="calculateOTAndBreak">
|
<select id="officeBreak" class="form-control" v-model.number="officeBreak" v-on:change="calculateOTAndBreak">
|
||||||
<option v-for="opt in breakOptions" :key="opt.value" :value="opt.value">
|
<option v-for="opt in breakOptions" :key="opt.value" :value="opt.value">
|
||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</option>
|
</option>
|
||||||
@ -46,16 +44,16 @@
|
|||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<label for="afterFrom">From</label>
|
<label for="afterFrom">From</label>
|
||||||
<input type="time" id="afterFrom" class="form-control" v-model="afterFrom"
|
<input type="time" id="afterFrom" class="form-control" v-model="afterFrom"
|
||||||
v-on:change ="afterFrom = roundToNearest30(afterFrom); calculateOTAndBreak()">
|
v-on:change="afterFrom = roundToNearest30(afterFrom); calculateOTAndBreak()">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<label for="afterTo">To</label>
|
<label for="afterTo">To</label>
|
||||||
<input type="time" id="afterTo" class="form-control" v-model="afterTo"
|
<input type="time" id="afterTo" class="form-control" v-model="afterTo"
|
||||||
v-on:change ="afterTo = roundToNearest30(afterTo); calculateOTAndBreak()">
|
v-on:change="afterTo = roundToNearest30(afterTo); calculateOTAndBreak()">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<label for="afterBreak">Break Hours (Minutes)</label>
|
<label for="afterBreak">Break Hours (Minutes)</label>
|
||||||
<select id="afterBreak" class="form-control" v-model.number="afterBreak" v-on:change ="calculateOTAndBreak">
|
<select id="afterBreak" class="form-control" v-model.number="afterBreak" v-on:change="calculateOTAndBreak">
|
||||||
<option v-for="opt in breakOptions" :key="opt.value" :value="opt.value">
|
<option v-for="opt in breakOptions" :key="opt.value" :value="opt.value">
|
||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</option>
|
</option>
|
||||||
@ -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,51 +148,165 @@
|
|||||||
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);
|
||||||
const minutes = totalMinutes % 60;
|
const minutes = totalMinutes % 60;
|
||||||
|
|
||||||
let label = '';
|
let label = '';
|
||||||
if (hours > 0) label += `${hours} hour${hours > 1 ? 's' : ''}`;
|
if (hours > 0) label += `${hours} hour${hours > 1 ? 's' : ''}`;
|
||||||
if (minutes > 0) label += `${label ? ' ' : ''}${minutes} min`;
|
if (minutes > 0) label += `${label ? ' ' : ''}${minutes} min`;
|
||||||
if (!label) label = '0 min';
|
if (!label) label = '0 min';
|
||||||
|
|
||||||
return { label, value: totalMinutes };
|
return { label, value: totalMinutes };
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
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";
|
||||||
|
|||||||
@ -11,59 +11,292 @@
|
|||||||
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"
|
||||||
<th>File</th>
|
:class="{'asc': sort.field === 'houStatus' && sort.order === 'asc', 'desc': sort.field === 'houStatus' && sort.order === 'desc'}"
|
||||||
</tr>
|
v-on:click="sortBy('houStatus')">HoU Status</th>
|
||||||
</thead>
|
<th v-if="includeHod" class="sortable-header"
|
||||||
<tbody>
|
:class="{'asc': sort.field === 'hodStatus' && sort.order === 'asc', 'desc': sort.field === 'hodStatus' && sort.order === 'desc'}"
|
||||||
<tr v-for="(item, index) in otRecords" :key="index">
|
v-on:click="sortBy('hodStatus')">HoD Status</th>
|
||||||
<td>{{ formatMonthYear(item.month, item.year) }}</td>
|
<th v-if="includeManager" class="sortable-header"
|
||||||
<td>{{ formatDate(item.submitDate) }}</td>
|
:class="{'asc': sort.field === 'managerStatus' && sort.order === 'asc', 'desc': sort.field === 'managerStatus' && sort.order === 'desc'}"
|
||||||
<td v-if="includeHou">{{ item.houStatus ?? '-' }}</td>
|
v-on:click="sortBy('managerStatus')">Manager Status</th>
|
||||||
<td v-if="includeHod">{{ item.hodStatus ?? '-' }}</td>
|
<th v-if="includeHr" class="sortable-header"
|
||||||
<td v-if="includeManager">{{ item.managerStatus ?? '-' }}</td>
|
:class="{'asc': sort.field === 'hrStatus' && sort.order === 'asc', 'desc': sort.field === 'hrStatus' && sort.order === 'desc'}"
|
||||||
<td v-if="includeHr">{{ item.hrStatus ?? '-' }}</td>
|
v-on:click="sortBy('hrStatus')">HR Status</th>
|
||||||
<td>{{ item.updated ? 'Yes' : 'No' }}</td>
|
<th>Edit History</th>
|
||||||
<td>
|
<th>File</th>
|
||||||
<button v-if="item.filePath" class="btn btn-sm btn-primary" v-on:click ="previewFile(item.filePath)">
|
</tr>
|
||||||
View
|
</thead>
|
||||||
</button>
|
<tbody>
|
||||||
<span v-else>-</span>
|
<tr v-for="(item, index) in filteredRecords" :key="index">
|
||||||
</td>
|
<td>{{ formatMonthYear(item.month, item.year) }}</td>
|
||||||
</tr>
|
<td>{{ formatDate(item.submitDate) }}</td>
|
||||||
<tr v-if="otRecords.length === 0">
|
<td v-if="includeHou">
|
||||||
<td :colspan="columnCount" class="text-center">No records found.</td>
|
<span v-if="item.houStatus" :class="getStatusBadgeClass(item.houStatus)">
|
||||||
</tr>
|
{{ item.houStatus }}
|
||||||
</tbody>
|
</span>
|
||||||
</table>
|
<span v-else>-</span>
|
||||||
|
</td>
|
||||||
|
<td v-if="includeHod">
|
||||||
|
<span v-if="item.hodStatus" :class="getStatusBadgeClass(item.hodStatus)">
|
||||||
|
{{ item.hodStatus }}
|
||||||
|
</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</td>
|
||||||
|
<td v-if="includeManager">
|
||||||
|
<span v-if="item.managerStatus" :class="getStatusBadgeClass(item.managerStatus)">
|
||||||
|
{{ item.managerStatus }}
|
||||||
|
</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</td>
|
||||||
|
<td v-if="includeHr">
|
||||||
|
<span v-if="item.hrStatus" :class="getStatusBadgeClass(item.hrStatus)">
|
||||||
|
{{ item.hrStatus }}
|
||||||
|
</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button v-if="item.updated" class="btn btn-sm btn-info" v-on:click="showEditHistory(item)">
|
||||||
|
<i class="fas fa-history"></i> View History
|
||||||
|
</button>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button v-if="item.filePath" class="btn btn-sm btn-primary" v-on:click="previewFile(item.filePath)">
|
||||||
|
<i class="fas fa-file-alt"></i> View
|
||||||
|
</button>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="filteredRecords.length === 0">
|
||||||
|
<td :colspan="columnCount" class="text-center">No records found matching your criteria.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div class="row mt-3" v-if="filteredRecords.length > 0">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="dataTables_info">
|
||||||
|
Showing {{ pagination.startItem }} to {{ pagination.endItem }} of {{ filteredRecords.length }} entries
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<nav aria-label="Page navigation" class="float-end">
|
||||||
|
<ul class="pagination pagination-sm">
|
||||||
|
<li class="page-item" :class="{disabled: pagination.currentPage === 1}">
|
||||||
|
<a class="page-link" href="#" v-on:click.prevent="prevPage">« 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 »</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 class="modal-body">
|
||||||
|
<iframe :src="previewUrl" width="100%" height="650px" style="border: none;"></iframe>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
</div>
|
||||||
<iframe :src="previewUrl" width="100%" height="650px" style="border: none;"></iframe>
|
</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>
|
||||||
@ -79,71 +312,462 @@
|
|||||||
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
|
||||||
|
},
|
||||||
|
|
||||||
|
monthYearOptions() {
|
||||||
|
const uniqueMonths = [...new Set(this.otRecords.map(item =>
|
||||||
|
`${item.month.toString().padStart(2, '0')}/${item.year}`
|
||||||
|
))];
|
||||||
|
return uniqueMonths.map(my => ({
|
||||||
|
value: my,
|
||||||
|
text: my
|
||||||
|
})).sort((a, b) => {
|
||||||
|
// Sort by year then month
|
||||||
|
const [aMonth, aYear] = a.value.split('/').map(Number);
|
||||||
|
const [bMonth, bYear] = b.value.split('/').map(Number);
|
||||||
|
return bYear - aYear || bMonth - aMonth;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
statusOptions() {
|
||||||
|
const options = [
|
||||||
|
{ value: 'Pending', text: 'Pending' },
|
||||||
|
{ value: 'Approved', text: 'Approved' },
|
||||||
|
{ value: 'Rejected', text: 'Rejected' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add combined status options if multiple approval levels exist
|
||||||
|
if ((this.includeHou && this.includeHod) ||
|
||||||
|
(this.includeHou && this.includeManager) ||
|
||||||
|
(this.includeHod && this.includeManager)) {
|
||||||
|
options.push(
|
||||||
|
{ value: 'PartiallyApproved', text: 'Partially Approved' },
|
||||||
|
{ value: 'FullyApproved', text: 'Fully Approved' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
},
|
||||||
|
|
||||||
|
filteredRecords() {
|
||||||
|
let filtered = [...this.otRecords];
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
if (this.filters.monthYear) {
|
||||||
|
const [month, year] = this.filters.monthYear.split('/').map(Number);
|
||||||
|
filtered = filtered.filter(item =>
|
||||||
|
item.month === month && item.year === year
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.filters.status) {
|
||||||
|
if (this.filters.status === 'PartiallyApproved') {
|
||||||
|
filtered = filtered.filter(item => {
|
||||||
|
const statuses = [];
|
||||||
|
if (this.includeHou) statuses.push(item.houStatus);
|
||||||
|
if (this.includeHod) statuses.push(item.hodStatus);
|
||||||
|
if (this.includeManager) statuses.push(item.managerStatus);
|
||||||
|
if (this.includeHr) statuses.push(item.hrStatus);
|
||||||
|
|
||||||
|
const approvedCount = statuses.filter(s => s === 'Approved').length;
|
||||||
|
const rejectedCount = statuses.filter(s => s === 'Rejected').length;
|
||||||
|
const pendingCount = statuses.filter(s => s === 'Pending' || !s).length;
|
||||||
|
|
||||||
|
return approvedCount > 0 && pendingCount > 0;
|
||||||
|
});
|
||||||
|
} else if (this.filters.status === 'FullyApproved') {
|
||||||
|
filtered = filtered.filter(item => {
|
||||||
|
const statuses = [];
|
||||||
|
if (this.includeHou) statuses.push(item.houStatus);
|
||||||
|
if (this.includeHod) statuses.push(item.hodStatus);
|
||||||
|
if (this.includeManager) statuses.push(item.managerStatus);
|
||||||
|
if (this.includeHr) statuses.push(item.hrStatus);
|
||||||
|
|
||||||
|
return statuses.every(s => s === 'Approved');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
filtered = filtered.filter(item => {
|
||||||
|
if (this.includeHou && item.houStatus === this.filters.status) return true;
|
||||||
|
if (this.includeHod && item.hodStatus === this.filters.status) return true;
|
||||||
|
if (this.includeManager && item.managerStatus === this.filters.status) return true;
|
||||||
|
if (this.includeHr && item.hrStatus === this.filters.status) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
let aValue, bValue;
|
||||||
|
|
||||||
|
switch (this.sort.field) {
|
||||||
|
case 'monthYear':
|
||||||
|
aValue = new Date(a.year, a.month - 1);
|
||||||
|
bValue = new Date(b.year, b.month - 1);
|
||||||
|
break;
|
||||||
|
case 'submitDate':
|
||||||
|
aValue = new Date(a.submitDate);
|
||||||
|
bValue = new Date(b.submitDate);
|
||||||
|
break;
|
||||||
|
case 'houStatus':
|
||||||
|
aValue = a.houStatus || '';
|
||||||
|
bValue = b.houStatus || '';
|
||||||
|
break;
|
||||||
|
case 'hodStatus':
|
||||||
|
aValue = a.hodStatus || '';
|
||||||
|
bValue = b.hodStatus || '';
|
||||||
|
break;
|
||||||
|
case 'managerStatus':
|
||||||
|
aValue = a.managerStatus || '';
|
||||||
|
bValue = b.managerStatus || '';
|
||||||
|
break;
|
||||||
|
case 'hrStatus':
|
||||||
|
aValue = a.hrStatus || '';
|
||||||
|
bValue = b.hrStatus || '';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aValue < bValue) return this.sort.order === 'asc' ? -1 : 1;
|
||||||
|
if (aValue > bValue) return this.sort.order === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update pagination
|
||||||
|
this.pagination.totalPages = Math.ceil(filtered.length / this.pagination.itemsPerPage);
|
||||||
|
this.pagination.currentPage = Math.min(this.pagination.currentPage, this.pagination.totalPages || 1);
|
||||||
|
|
||||||
|
const startIndex = (this.pagination.currentPage - 1) * this.pagination.itemsPerPage;
|
||||||
|
const endIndex = startIndex + this.pagination.itemsPerPage;
|
||||||
|
|
||||||
|
this.pagination.startItem = filtered.length > 0 ? startIndex + 1 : 0;
|
||||||
|
this.pagination.endItem = Math.min(endIndex, filtered.length);
|
||||||
|
|
||||||
|
return filtered.slice(startIndex, endIndex);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
|
||||||
fetch('/OvertimeAPI/GetUserOtStatus')
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
this.includeHou = data.includeHou;
|
|
||||||
this.includeHod = data.includeHod;
|
|
||||||
this.includeManager = data.includeManager;
|
|
||||||
this.includeHr = data.includeHr;
|
|
||||||
this.otRecords = (data.otStatuses || []).sort((a, b) => {
|
|
||||||
// Sort in descending order of SubmitDate (latest first)
|
|
||||||
return new Date(b.submitDate) - new Date(a.submitDate);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.$nextTick(() => {
|
mounted() {
|
||||||
// Delay to ensure table is rendered
|
Promise.all([
|
||||||
if ($.fn.dataTable.isDataTable('#otStatusTable')) {
|
fetch('/OvertimeAPI/GetUserOtStatus')
|
||||||
$('#otStatusTable').DataTable().destroy();
|
.then(res => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error('Failed to fetch OT status');
|
||||||
}
|
}
|
||||||
$('#otStatusTable').DataTable({
|
return res.json();
|
||||||
responsive: true,
|
})
|
||||||
pageLength: 10,
|
.catch(error => {
|
||||||
order: []
|
console.error('Error fetching OT status:', error);
|
||||||
});
|
return {
|
||||||
});
|
includeHou: false,
|
||||||
})
|
includeHod: false,
|
||||||
.catch(err => {
|
includeManager: false,
|
||||||
console.error("Error fetching OT records:", err);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -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; }
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>();
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user