diff --git a/Areas/Bookings/Controllers/BookingsController.cs b/Areas/Bookings/Controllers/BookingsController.cs new file mode 100644 index 0000000..9130802 --- /dev/null +++ b/Areas/Bookings/Controllers/BookingsController.cs @@ -0,0 +1,93 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using PSTW_CentralSystem.DBContext; + +namespace PSTW_CentralSystem.Areas.Bookings.Controllers +{ + [Area("Bookings")] + [Authorize] // require login for everything here + public class BookingsController : Controller + { + private readonly CentralSystemContext _db; + private readonly ILogger _logger; + + public BookingsController(CentralSystemContext db, ILogger logger) + { + _db = db; + _logger = logger; + } + + // ---------- helpers ---------- + private int? GetCurrentUserId() + { + var idStr = User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + return int.TryParse(idStr, out var id) ? id : (int?)null; + } + + // DB-backed manager check (NO Identity roles here) + private Task IsManagerAsync() + { + var me = GetCurrentUserId(); + if (me is null) return Task.FromResult(false); + return _db.BookingManager.AsNoTracking() + .AnyAsync(x => x.UserId == me.Value && x.IsActive); + } + + private Task AnyManagersAsync() + => _db.BookingManager.AsNoTracking().AnyAsync(); + + private async Task RequireManagerOrForbidAsync() + { + if (await IsManagerAsync()) return null; + return Forbid(); // or RedirectToAction(nameof(Index)); + } + + // ---------- pages ---------- + public IActionResult Index() => View(); + + // Manager-only (rooms list/maintenance) + public async Task Room() + { + var gate = await RequireManagerOrForbidAsync(); + if (gate is not null) return gate; + return View(); + } + + // Manager-only (create/edit room) + public async Task RoomsCreate() + { + var gate = await RequireManagerOrForbidAsync(); + if (gate is not null) return gate; + return View(); + } + + // Everyone can view the calendar + public IActionResult Calendar() => View(); + + // Managers page: + // - Bootstrap: if no managers exist yet, allow any authenticated user to seed. + // - Otherwise: only managers. + public async Task Managers() + { + if (!await AnyManagersAsync()) + { + ViewBag.Bootstrap = true; // optional UI hint + return View(); + } + + var gate = await RequireManagerOrForbidAsync(); + if (gate is not null) return gate; + + ViewBag.Bootstrap = false; + return View(); + } + + // Create/Edit booking (JS loads data by id) + public IActionResult Create(int? id) + { + ViewBag.Id = id; + return View(); + } + } +} diff --git a/Areas/Bookings/Models/BookingManager.cs b/Areas/Bookings/Models/BookingManager.cs new file mode 100644 index 0000000..a4d768f --- /dev/null +++ b/Areas/Bookings/Models/BookingManager.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PSTW_CentralSystem.Areas.Bookings.Models +{ + /// + /// Dynamic list of users who have "manager" powers in the Room Booking module. + /// Keep it simple (global scope); you can extend with CompanyId/DepartmentId/RoomId later. + /// + [Table("booking_managers")] + public class BookingManager + { + [Key] + public int BookingManagerId { get; set; } + + /// FK → aspnetusers(Id) (int) + [Required] + public int UserId { get; set; } + + public bool IsActive { get; set; } = true; + + [Required] + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + + public int? CreatedByUserId { get; set; } + } +} diff --git a/Areas/Bookings/Models/BookingsModel.cs b/Areas/Bookings/Models/BookingsModel.cs new file mode 100644 index 0000000..1652f52 --- /dev/null +++ b/Areas/Bookings/Models/BookingsModel.cs @@ -0,0 +1,88 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PSTW_CentralSystem.Areas.Bookings.Models +{ + public enum BookingStatus + { + Pending = 0, + Approved = 1, + Rejected = 2, + Cancelled = 3 + } + + [Table("bookings")] + public class Booking : IValidatableObject + { + [Key] + [Column("BookingId")] + public int BookingId { get; set; } + + /// FK → aspnetusers(Id) (int) + [Required] + public int RequestedByUserId { get; set; } + + /// If booking on behalf of someone else; else null. + public int? TargetUserId { get; set; } + + /// Snapshot of org at submission time. + public int? DepartmentId { get; set; } // FK → departments(DepartmentId) + public int? CompanyId { get; set; } // FK → companies(CompanyId) + + /// Room being booked. + [Required] + public int RoomId { get; set; } // FK → rooms(RoomId) + + [Required, StringLength(150)] + public string Title { get; set; } = string.Empty; + + [StringLength(300)] + public string? Purpose { get; set; } + + /// Use UTC to avoid TZ headaches; map to DATETIME in MySQL. + [Required] + public DateTime StartUtc { get; set; } + + [Required] + public DateTime EndUtc { get; set; } + + [StringLength(500)] + public string? Note { get; set; } + + [Required] + public DateTime CreatedUtc { get; set; } = DateTime.UtcNow; + + [Required] + public DateTime LastUpdatedUtc { get; set; } = DateTime.UtcNow; + + [Required] + public BookingStatus CurrentStatus { get; set; } = BookingStatus.Pending; + + // ---- validation ---- + public IEnumerable Validate(ValidationContext _) + { + if (EndUtc <= StartUtc) + yield return new ValidationResult("End time must be after start time.", + new[] { nameof(EndUtc) }); + + if ((EndUtc - StartUtc).TotalMinutes < 10) + yield return new ValidationResult("Minimum booking duration is 10 minutes.", + new[] { nameof(StartUtc), nameof(EndUtc) }); + } + } + + [Table("rooms")] + public class Room + { + [Key] public int RoomId { get; set; } + + [Required, StringLength(120)] + public string RoomName { get; set; } = string.Empty; + + [StringLength(40)] + public string? LocationCode { get; set; } + + public int? Capacity { get; set; } + public bool IsActive { get; set; } = true; + } +} diff --git a/Areas/Bookings/Views/Bookings/Calendar.cshtml b/Areas/Bookings/Views/Bookings/Calendar.cshtml new file mode 100644 index 0000000..370817c --- /dev/null +++ b/Areas/Bookings/Views/Bookings/Calendar.cshtml @@ -0,0 +1,721 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers + +@{ + ViewData["Title"] = "Bookings Calendar"; + Layout = "_Layout"; +} + + + + + +
+ +
+
+
+ + + + +
+
+ + +
+
+ +
+ Free + Partially booked + Busy +
+ +
+
+
+ + + + +@section Scripts { + + + +} + diff --git a/Areas/Bookings/Views/Bookings/Create.cshtml b/Areas/Bookings/Views/Bookings/Create.cshtml new file mode 100644 index 0000000..a191689 --- /dev/null +++ b/Areas/Bookings/Views/Bookings/Create.cshtml @@ -0,0 +1,541 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers + +@{ + + Layout = "_Layout"; +} + + + +
+
+
+

Create Booking

+
+
+ +
+
+ + + +
+
Details
+
+
+
+ + +
Max 150 characters.
+
Title is required (max 150 chars).
+
+ +
+ + +
Only active rooms are shown.
+
Please select a room.
+
+
+
+
+ + +
+
Schedule
+
+
+
+ + +
Local time; saved as UTC. 30-minute slots (e.g., 10:30, 11:00).
+
+ +
+ + +
Must be the same date as Start and after Start.
+
+
+
+
+ + +
+
Requester
+
+
+
+ + +
We’ll link the booking to this user.
+
Please select a user.
+
+ +
+ + +
Up to 300 characters. Included in Description server-side.
+
+
+
+
+ + +
+
+ Cancel + +
+
+
+
+
+
+ +@section Scripts { + + +} diff --git a/Areas/Bookings/Views/Bookings/Index.cshtml b/Areas/Bookings/Views/Bookings/Index.cshtml new file mode 100644 index 0000000..4ac175a --- /dev/null +++ b/Areas/Bookings/Views/Bookings/Index.cshtml @@ -0,0 +1,861 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@using System.Security.Claims +@{ + ViewData["Title"] = "Bookings"; + Layout = "_Layout"; + + var isMgr = User?.IsInRole("Manager") == true; + var idStr = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value; + var meId = int.TryParse(idStr, out var tmp) ? tmp : 0; +} + + + + + +
+
+

+
+ + Create New +
+
+ +
+ + + + +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + + + + + @if (isMgr) + { + + } + + + + + + + + + + @if (isMgr) + { + + } + + + + + + +
TitleNotesRoomDateTimeUserActions
Loading…
+ + +
+
+ + +
+
+ Page 1 of 1 + (Showing 0–0 of 0) +
+
+ + +
+
+
+
+ + + + +@section Scripts { + +} diff --git a/Areas/Bookings/Views/Bookings/Managers.cshtml b/Areas/Bookings/Views/Bookings/Managers.cshtml new file mode 100644 index 0000000..2b4edda --- /dev/null +++ b/Areas/Bookings/Views/Bookings/Managers.cshtml @@ -0,0 +1,227 @@ +@{ + ViewData["Title"] = "Booking Managers"; + Layout = "~/Views/Shared/_Layout.cshtml"; +} + + + + + +
+
+
+
Booking Managers
+
+ + +
+
+ +
+
{{ error }}
+
+ Select users who should act as Managers for the Room Booking module (approve/reject, manage rooms, etc.). +
+ +
+ +
+
+ + +
+ +
+ +
No users match your search.
+
+
+ + +
+
+ Selected ({{ selectedUsers.length }}) + +
+ +
+ + {{ u.name }} + + +
Nobody selected yet.
+
+ +
+ Managers can: approve/reject bookings, create/update rooms, cancel/un-cancel any booking, etc. +
+
+
+
+
+
+ + diff --git a/Areas/Bookings/Views/Bookings/Room.cshtml b/Areas/Bookings/Views/Bookings/Room.cshtml new file mode 100644 index 0000000..6b20fd6 --- /dev/null +++ b/Areas/Bookings/Views/Bookings/Room.cshtml @@ -0,0 +1,268 @@ +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@{ + ViewData["Title"] = "Rooms"; + Layout = "_Layout"; +} + + +
+

+ + +
+ +
+ +
+ + + + + + + + + + + +
NameLocationCapacityActiveActions
Loading…
+
+ + + + +@section Scripts { + + +} + diff --git a/Areas/IT/Controllers/ApprovalDashboardController.cs b/Areas/IT/Controllers/ApprovalDashboardController.cs new file mode 100644 index 0000000..12c9cf6 --- /dev/null +++ b/Areas/IT/Controllers/ApprovalDashboardController.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace PSTW_CentralSystem.Areas.IT.Controllers +{ + [Area("IT")] + [Authorize] + public class ApprovalDashboardController : Controller + { + public IActionResult Approval() + { + return View(); // ~/Areas/IT/Views/ApprovalDashboard/Approval.cshtml + } + + public IActionResult Create() + { + return View(); // ~/Areas/IT/Views/ApprovalDashboard/Create.cshtml + } + public IActionResult MyRequests() + { + return View(); // ~/Areas/IT/Views/ApprovalDashboard/MyRequests.cshtml + } + public IActionResult Admin() + { + return View(); // ~/Areas/IT/Views/ApprovalDashboard/MyRequests.cshtml + } + public IActionResult RequestReview(int statusId) + { + ViewBag.StatusId = statusId; + return View(); // ~/Areas/IT/Views/ApprovalDashboard/RequestReview.cshtml + } + public IActionResult SectionB() + { + return View(); + } + + public IActionResult Edit() + { + return View(); + } + + public IActionResult SectionBEdit() + { + return View(); + } + } +} diff --git a/Areas/IT/Models/ItApprovalFlow.cs b/Areas/IT/Models/ItApprovalFlow.cs new file mode 100644 index 0000000..a1a68cd --- /dev/null +++ b/Areas/IT/Models/ItApprovalFlow.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PSTW_CentralSystem.Areas.IT.Models +{ + [Table("it_approval_flows")] + public class ItApprovalFlow + { + [Key] + public int ItApprovalFlowId { get; set; } + + [MaxLength(200)] + public string FlowName { get; set; } + + // approvers + public int HodUserId { get; set; } + public int GroupItHodUserId { get; set; } + public int FinHodUserId { get; set; } + public int MgmtUserId { get; set; } + } +} diff --git a/Areas/IT/Models/ItRequest.cs b/Areas/IT/Models/ItRequest.cs new file mode 100644 index 0000000..439058a --- /dev/null +++ b/Areas/IT/Models/ItRequest.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PSTW_CentralSystem.Areas.IT.Models +{ + [Table("it_requests")] + public class ItRequest + { + [Key] + public int ItRequestId { get; set; } + + public int UserId { get; set; } // FK -> aspnetusers.Id + + // snapshot fields (taken at submission time) + [Required] + [MaxLength(200)] + public string StaffName { get; set; } = string.Empty; + + [MaxLength(200)] + public string? CompanyName { get; set; } + + [MaxLength(200)] + public string? DepartmentName { get; set; } + + [MaxLength(200)] + public string? Designation { get; set; } + + [MaxLength(200)] + public string? Location { get; set; } + + [MaxLength(50)] + public string? EmploymentStatus { get; set; } // Permanent / Contract / Temp / New Staff + + public DateTime? ContractEndDate { get; set; } + + public DateTime RequiredDate { get; set; } + + [MaxLength(20)] + public string? PhoneExt { get; set; } + + public DateTime SubmitDate { get; set; } + + // navigation + public ICollection Hardware { get; set; } = new List(); + public ICollection Emails { get; set; } = new List(); + public ICollection OsRequirements { get; set; } = new List(); + public ICollection Software { get; set; } = new List(); + public ICollection SharedPermissions { get; set; } = new List(); + + public DateTime? FirstSubmittedAt { get; set; } // when the request was first created + public DateTime? EditableUntil { get; set; } // FirstSubmittedAt + window + public bool IsLockedForEdit { get; set; } + } +} diff --git a/Areas/IT/Models/ItRequestAssetInfo.cs b/Areas/IT/Models/ItRequestAssetInfo.cs new file mode 100644 index 0000000..69ee9ea --- /dev/null +++ b/Areas/IT/Models/ItRequestAssetInfo.cs @@ -0,0 +1,36 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PSTW_CentralSystem.Areas.IT.Models +{ + [Table("it_request_asset_info")] + + public class ItRequestAssetInfo + { + public int Id { get; set; } + public int ItRequestId { get; set; } + + public string? AssetNo { get; set; } + public string? MachineId { get; set; } + public string? IpAddress { get; set; } + public string? WiredMac { get; set; } + public string? WifiMac { get; set; } + public string? DialupAcc { get; set; } + public string? Remarks { get; set; } + + public DateTime? LastEditedAt { get; set; } + public int? LastEditedByUserId { get; set; } + public string? LastEditedByName { get; set; } + + public bool RequestorAccepted { get; set; } + public DateTime? RequestorAcceptedAt { get; set; } + + public bool ItAccepted { get; set; } + public DateTime? ItAcceptedAt { get; set; } + public int? ItAcceptedByUserId { get; set; } + public string? ItAcceptedByName { get; set; } + + public bool SectionBSent { get; set; } // default false + public DateTime? SectionBSentAt { get; set; } + } +} diff --git a/Areas/IT/Models/ItRequestEmail.cs b/Areas/IT/Models/ItRequestEmail.cs new file mode 100644 index 0000000..97dbf01 --- /dev/null +++ b/Areas/IT/Models/ItRequestEmail.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PSTW_CentralSystem.Areas.IT.Models +{ + [Table("it_request_emails")] + public class ItRequestEmail + { + [Key] + public int Id { get; set; } + + public int ItRequestId { get; set; } + [ForeignKey("ItRequestId")] + public ItRequest? Request { get; set; } + + [MaxLength(50)] + public string? Purpose { get; set; } // New / Replacement / Additional + + [MaxLength(200)] + public string? ProposedAddress { get; set; } + + public string? Notes { get; set; } + } +} diff --git a/Areas/IT/Models/ItRequestHardware.cs b/Areas/IT/Models/ItRequestHardware.cs new file mode 100644 index 0000000..8a6fd35 --- /dev/null +++ b/Areas/IT/Models/ItRequestHardware.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PSTW_CentralSystem.Areas.IT.Models +{ + [Table("it_request_hardware")] + public class ItRequestHardware + { + [Key] + public int Id { get; set; } + + public int ItRequestId { get; set; } + [ForeignKey("ItRequestId")] + public ItRequest? Request { get; set; } + + [MaxLength(100)] + public string Category { get; set; } = ""; // Notebook, Desktop, etc. + + [MaxLength(100)] + public string? Purpose { get; set; } // New / Replacement / Additional + + public string? Justification { get; set; } + + public string? OtherDescription { get; set; } + } +} diff --git a/Areas/IT/Models/ItRequestOsRequirement.cs b/Areas/IT/Models/ItRequestOsRequirement.cs new file mode 100644 index 0000000..201c49b --- /dev/null +++ b/Areas/IT/Models/ItRequestOsRequirement.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PSTW_CentralSystem.Areas.IT.Models +{ + [Table("it_request_osreqs")] + public class ItRequestOsRequirement + { + [Key] + public int Id { get; set; } + + public int ItRequestId { get; set; } + [ForeignKey("ItRequestId")] + public ItRequest? Request { get; set; } + + public string? RequirementText { get; set; } + } +} diff --git a/Areas/IT/Models/ItRequestSharedPermission.cs b/Areas/IT/Models/ItRequestSharedPermission.cs new file mode 100644 index 0000000..4d52100 --- /dev/null +++ b/Areas/IT/Models/ItRequestSharedPermission.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PSTW_CentralSystem.Areas.IT.Models +{ + [Table("it_request_sharedperms")] + public class ItRequestSharedPermission + { + [Key] + public int Id { get; set; } + + public int ItRequestId { get; set; } + [ForeignKey("ItRequestId")] + public ItRequest? Request { get; set; } + + [MaxLength(100)] + public string? ShareName { get; set; } + + public bool CanRead { get; set; } + public bool CanWrite { get; set; } + public bool CanDelete { get; set; } + public bool CanRemove { get; set; } + } +} diff --git a/Areas/IT/Models/ItRequestSoftware.cs b/Areas/IT/Models/ItRequestSoftware.cs new file mode 100644 index 0000000..fa906e8 --- /dev/null +++ b/Areas/IT/Models/ItRequestSoftware.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PSTW_CentralSystem.Areas.IT.Models +{ + [Table("it_request_software")] + public class ItRequestSoftware + { + [Key] + public int Id { get; set; } + + public int ItRequestId { get; set; } + [ForeignKey("ItRequestId")] + public ItRequest? Request { get; set; } + + [MaxLength(50)] + public string Bucket { get; set; } = ""; // General, Utility, Custom + + [MaxLength(200)] + public string Name { get; set; } = ""; + + public string? OtherName { get; set; } + + public string? Notes { get; set; } + } +} diff --git a/Areas/IT/Models/ItRequestStatus.cs b/Areas/IT/Models/ItRequestStatus.cs new file mode 100644 index 0000000..59ebc09 --- /dev/null +++ b/Areas/IT/Models/ItRequestStatus.cs @@ -0,0 +1,37 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PSTW_CentralSystem.Areas.IT.Models +{ + [Table("it_request_status")] + public class ItRequestStatus + { + [Key] + public int StatusId { get; set; } + + public int ItRequestId { get; set; } + [ForeignKey("ItRequestId")] + public ItRequest? Request { get; set; } + + public int ItApprovalFlowId { get; set; } + [ForeignKey("ItApprovalFlowId")] + public ItApprovalFlow? Flow { get; set; } + + // per-stage statuses + [MaxLength(20)] public string? HodStatus { get; set; } + [MaxLength(20)] public string? GitHodStatus { get; set; } + [MaxLength(20)] public string? FinHodStatus { get; set; } + [MaxLength(20)] public string? MgmtStatus { get; set; } + + public DateTime? HodSubmitDate { get; set; } + public DateTime? GitHodSubmitDate { get; set; } + public DateTime? FinHodSubmitDate { get; set; } + public DateTime? MgmtSubmitDate { get; set; } + + + + [MaxLength(20)] + public string? OverallStatus { get; set; } // Pending / Approved / Rejected + } +} diff --git a/Areas/IT/Models/ItTeamMember.cs b/Areas/IT/Models/ItTeamMember.cs new file mode 100644 index 0000000..591282d --- /dev/null +++ b/Areas/IT/Models/ItTeamMember.cs @@ -0,0 +1,12 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace PSTW_CentralSystem.Areas.IT.Models +{ + [Table("it_team_members")] + public class ItTeamMember + { + public int Id { get; set; } + public int UserId { get; set; } + } +} diff --git a/Areas/IT/Printing/ItRequestPdfService.cs b/Areas/IT/Printing/ItRequestPdfService.cs new file mode 100644 index 0000000..1611edc --- /dev/null +++ b/Areas/IT/Printing/ItRequestPdfService.cs @@ -0,0 +1,730 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using QuestPDF.Drawing; +using QuestPDF.Fluent; +using QuestPDF.Helpers; +using QuestPDF.Infrastructure; + +namespace PSTW_CentralSystem.Areas.IT.Printing +{ + public class ItRequestPdfService + { + public byte[] Generate(ItRequestReportModel m) + { + QuestPDF.Settings.License = LicenseType.Community; + + return Document.Create(container => + { + container.Page(page => + { + page.Size(PageSizes.A4); + page.Margin(14); + page.DefaultTextStyle(x => x.FontSize(7)); + + page.Content().Column(col => + { + col.Item().Element(e => HeaderStrip(e, m)); + + + col.Item().PaddingTop(4).Text( + "This form is digitally generated. Submit to http://support.transwater.com.my"); + + // ===== SECTION A ===== + + col.Item().PaddingTop(4).Text("Section A").Bold().FontSize(7); + col.Item().Element(e => SectionA_IdentityEmployment(e, m)); + + + + // two-column layout (left = Hardware+OS+Software, right = Email+Internet+Shared+Copier) + col.Item().Table(t => + { + t.ColumnsDefinition(cols => + { + cols.RelativeColumn(1); // LEFT + cols.ConstantColumn(8); // gutter + cols.RelativeColumn(1); // RIGHT + }); + + // left cell (entire left stack) + t.Cell() + .Element(x => x.MinHeight(380)) // optional: set a floor so both look balanced + .Element(e => SectionA_HardwareOsSoftware(e, m)); + + // gutter cell + t.Cell().Text(""); + + // right cell (entire right stack) + t.Cell() + .Element(x => x.MinHeight(380)) // same floor as left + .Element(e => SectionA_RightPane_EmailInternetShared(e, m)); + }); + col.Item().Element(e => FormArrangementBlock(e, m)); + col.Item().Element(e => SectionB_TwoBlocks(e, m)); + }); + }); + }).GeneratePdf(); + } + + // ---------------- helpers & styles ---------------- + #region HELPERS + static string Box(bool on) => on ? "☒" : "☐"; + static string F(DateTime? dt) => dt.HasValue ? dt.Value.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture) : ""; + static IContainer Cell(IContainer x) => x.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(4); + static IContainer CellHead(IContainer x) => x.Background(Colors.Grey.Lighten4).Border(1).BorderColor(Colors.Grey.Lighten2).Padding(4); + static IContainer CellMandatory(IContainer x) => x.Background(Colors.Grey.Lighten2).Border(1).BorderColor(Colors.Grey.Lighten2).Padding(4); + #endregion + + // ================= HEADER (boxed like your screenshot) ================= + void HeaderStrip(IContainer c, ItRequestReportModel m) + { + c.Border(1).Padding(0).Table(t => + { + t.ColumnsDefinition(cols => + { + cols.RelativeColumn(2); // LEFT: big title + cols.RelativeColumn(1); // RIGHT: meta table + }); + + // LEFT pane: centered title + t.Cell().BorderRight(1).Padding(8).MinHeight(10).AlignCenter().AlignMiddle().Column(left => + { + left.Item().Text("I.T. REQUEST FORM").SemiBold().FontSize(14).AlignCenter(); + left.Item().Text("(Group IT)").SemiBold().FontSize(14).AlignCenter(); + }); + + // RIGHT pane: 2-column grid with borders + t.Cell().Padding(0).Element(x => RightMetaBox(x, m)); + }); + } + + // draws the right-side 2-column table with boxed rows + void RightMetaBox(IContainer c, ItRequestReportModel m) + { + c.Table(t => + { + t.ColumnsDefinition(cols => + { + cols.RelativeColumn(1); // label + cols.RelativeColumn(1); // value + }); + + void Row(string label, string value, bool isLast = false) + { + // label cell (left) + t.Cell().BorderBottom(1).Padding(2).Text(label); + // value cell (right) — add vertical divider between label/value + t.Cell().BorderLeft(1).BorderBottom(1).Padding(0).AlignMiddle().Text(value ?? ""); + } + + Row("Document No.", m.DocumentNo); + Row("Effective Date", (m.EffectiveDate == default) ? "" : m.EffectiveDate.ToString("dd/MM/yyyy")); + Row("Rev. No", m.RevNo); // <- shows dynamic StatusId value + Row("Doc. Page No", m.DocPageNo); // last row still gets bottom border for boxed look + }); + } + + + // ================= SECTION A ================= + #region SECTION A • Identity & Employment + // ================= SECTION A – Identity & Employment (refined layout) ================= + // SECTION A — Identity + Employment as ONE BLOCK (matches screenshot) + // ================= SECTION A – Compact Unified Block (new refined layout) ================= + void SectionA_IdentityEmployment(IContainer c, ItRequestReportModel m) + { + // helpers + var emp = (m.EmploymentStatus ?? "").Trim().ToLowerInvariant(); + bool isPerm = emp == "permanent"; + bool isContract = emp == "contract"; + bool isTemp = emp == "temp" || emp == "temporary"; + bool isNew = emp == "new staff" || emp == "new"; + + IContainer L(IContainer x) => x.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(4); + IContainer V(IContainer x) => x.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(4); + + c.Table(t => + { + // 6-column consistent grid + t.ColumnsDefinition(cols => + { + cols.RelativeColumn(1); // L1 + cols.RelativeColumn(2); // V1 + cols.RelativeColumn(1); // L2 + cols.RelativeColumn(2); // V2 + cols.RelativeColumn(1); // L3 + cols.RelativeColumn(2); // V3 + }); + + // === Row 1: Staff Name / Company / Div === + t.Cell().Element(L).Text("Staff Name:"); + t.Cell().Element(V).Text(m.StaffName ?? ""); + t.Cell().Element(L).Text("Company:"); + t.Cell().Element(V).Text(m.CompanyName ?? ""); + t.Cell().Element(L).Text("Div/Dept:"); + t.Cell().Element(V).Text(m.DepartmentName ?? ""); + + // === Row 2: Designation / Location === + t.Cell().Element(L).Text("Designation:"); + t.Cell().Element(V).Text(m.Designation ?? ""); + t.Cell().Element(L).Text("Location:"); + t.Cell().Element(V).Text(m.Location ?? ""); + // fill the last two cells to maintain full-width grid + t.Cell().Element(L).Text(""); + t.Cell().Element(V).Text(""); + + // === Row 3: Employment Status + Contract End Date beside it === + t.Cell().Element(L).Text("Employment Status:"); + t.Cell().ColumnSpan(3).Element(V).Row(r => + { + r.Spacing(12); + r.ConstantItem(50).Text($"{Box(isPerm)} Permanent"); + r.ConstantItem(50).Text($"{Box(isContract)} Contract"); + r.ConstantItem(50).Text($"{Box(isTemp)} Temp"); + r.ConstantItem(50).Text($"{Box(isNew)} New Staff"); + }); + + t.Cell().Element(L).Text("If Temp / Contract End Date:"); + t.Cell().Element(V).Text(F(m.ContractEndDate)); + + // === Row 4: Phone Ext + Required Date beside it === + t.Cell().Element(L).Text("Phone Ext:"); + t.Cell().Element(V).Text(m.PhoneExt ?? ""); + t.Cell().Element(L).Text("Required Date:"); + t.Cell().Element(V).Text(F(m.RequiredDate)); + // keep remaining two cells empty to close grid + t.Cell().Element(L).Text(""); + t.Cell().Element(V).Text(""); + }); + } + + + + + #endregion + + #region SECTION A • HardwareOsSoftware + + // ===================== SECTION A – HARDWARE + OS + SOFTWARE ===================== + void SectionA_HardwareOsSoftware(IContainer c, ItRequestReportModel m) + { + bool HasSoft(string name) => + m.Software.Any(s => (s.Name ?? "").Equals(name, StringComparison.InvariantCultureIgnoreCase)); + + var general = new[] { "MS Word", "MS Excel", "MS Outlook", "MS PowerPoint", "MS Access", "MS Project", "Acrobat Standard", "AutoCAD", "Worktop/ERP Login" }; + var utility = new[] { "PDF Viewer", "7Zip", "AutoCAD Viewer", "Smart Draw" }; + + c.Table(t => + { + t.ColumnsDefinition(cols => + { + cols.RelativeColumn(1); + }); + + // --- HEADER: Hardware Requirements --- + t.Cell().Element(x => x.Background(Colors.Black).Padding(3)) + .Text("Hardware Requirements").FontColor(Colors.White).Bold(); + + // --- Hardware Body --- + t.Cell().Border(1).Padding(1).Column(col => + { + col.Spacing(5); + col.Item().Row(r => + { + // Left column: Purpose + Justification + r.RelativeItem().Column(left => + { + left.Item().Text($"{Box(m.HwPurposeNewRecruitment)} New Staff Recruitment"); + left.Item().Text($"{Box(m.HwPurposeReplacement)} Replacement"); + left.Item().Text($"{Box(m.HwPurposeAdditional)} Additional"); + left.Item().PaddingTop(5).Text("Justification (for hardware change) :"); + left.Item().Border(1).Height(40).Padding(4) + .Text(string.IsNullOrWhiteSpace(m.Justification) ? "" : m.Justification); + }); + + // Right column: Category selection + r.RelativeItem().Column(right => + { + right.Item().Text("Select below:"); + right.Item().Text($"{Box(m.HwDesktopAllIn)} Desktop (all inclusive)"); + right.Item().Text($"{Box(m.HwNotebookAllIn)} Notebook (all inclusive)"); + right.Item().Text($"{Box(m.HwDesktopOnly)} Desktop only"); + right.Item().Text($"{Box(m.HwNotebookOnly)} Notebook only"); + right.Item().Text($"{Box(m.HwNotebookBattery)} Notebook battery"); + right.Item().Text($"{Box(m.HwPowerAdapter)} Power Adapter"); + right.Item().Text($"{Box(m.HwMouse)} Computer Mouse"); + right.Item().Text($"{Box(m.HwExternalHdd)} External Hard Drive"); + right.Item().Text("Other (Specify):"); + right.Item().Border(1).Height(18).Padding(3) + .Text(string.IsNullOrWhiteSpace(m.HwOtherText) ? "-" : m.HwOtherText); + }); + }); + }); + + // --- HEADER: OS Requirements --- + t.Cell().Element(x => x.Background(Colors.Black).Padding(3)) + .Text("OS Requirements (Leave blank if no specific requirement)") + .FontColor(Colors.White).Bold(); + + // --- OS Body --- + t.Cell().Border(1).Padding(5).Column(col => + { + col.Item().Text("Requirements:"); + col.Item().Border(1).Height(30).Padding(4) + .Text(m.OsRequirements.Any() ? string.Join(Environment.NewLine, m.OsRequirements) : "-"); + }); + + // --- HEADER: Software Requirements --- + t.Cell().Element(x => x.Background(Colors.Black).Padding(3)) + .Text("Software Requirements").FontColor(Colors.White).Bold(); + + // --- Software Body (3 columns) --- + t.Cell().Border(1).Padding(5).Table(st => + { + st.ColumnsDefinition(cols => + { + cols.RelativeColumn(1); // General + cols.RelativeColumn(1); // Utility + cols.RelativeColumn(1); // Custom + }); + + // Headings + st.Header(h => + { + h.Cell().Element(CellHead).Text("General Software"); + h.Cell().Element(CellHead).Text("Utility Software"); + h.Cell().Element(CellHead).Text("Custom Software"); + }); + + int maxRows = Math.Max(general.Length, utility.Length); + for (int i = 0; i < maxRows; i++) + { + st.Cell().Element(Cell) + .Text(i < general.Length ? $"{Box(HasSoft(general[i]))} {general[i]}" : ""); + st.Cell().Element(Cell) + .Text(i < utility.Length ? $"{Box(HasSoft(utility[i]))} {utility[i]}" : ""); + + if (i == 0) + { + st.Cell().Element(Cell).Text("Others (Specify) :"); + } + else if (i == 1) + { + st.Cell().Element(Cell).Border(1).Height(15).Padding(3).Text("-"); + } + else st.Cell().Element(Cell).Text(""); + } + }); + }); + } + + + + #endregion + + #region SECTION A • EmailInternetSharedperm + // ===================== SECTION A – RIGHT PANE ===================== + void SectionA_RightPane_EmailInternetShared(IContainer c, ItRequestReportModel m) + { + c.Column(col => + { + // ===== Email ===== + col.Item().Element(x => x.Background(Colors.Black).Padding(3)) + .Text("Email").FontColor(Colors.White).Bold(); + + col.Item().Border(1).Padding(6).Column(cc => + { + // single-line "Email Address:" with inline box + cc.Item().Row(r => + { + r.ConstantItem(100).Text("Email Address:"); // label column width + r.RelativeItem().Border(1).Height(16).PaddingHorizontal(4).AlignMiddle() + .Text(m.Emails.Any() ? string.Join("; ", m.Emails) : ""); + }); + + cc.Item().PaddingTop(4).Text("Arrangement Guide: FirstName&LastName or Name&Surname or Surname&Name."); + cc.Item().Text("Full name and short form/ initial is allowed to be used if the name is too long."); + cc.Item().Text("e.g. siti.nurhaliza, jackie.chan, ks.chan"); + }); + + // ===== Internet Permissions ===== + col.Item().Element(x => x.Background(Colors.Black).Padding(3)) + .Text("Internet Permissions").FontColor(Colors.White).Bold(); + + col.Item().Border(1).Padding(0).Column(cc => + { + cc.Item().BorderBottom(1).Padding(6).Text($"{Box(false)} Unlimited Internet Access"); + cc.Item().BorderBottom(1).Padding(6).Text($"{Box(false)} Limited Internet Access"); + cc.Item().BorderBottom(1).Padding(6).Text($"{Box(false)} PSI Instant Messenger"); + cc.Item().BorderBottom(1).Padding(6).Text($"{Box(false)} VPN"); + + cc.Item().PaddingLeft(14).PaddingTop(4).Text("Justification for the Internet Permissions:"); + cc.Item().Padding(6).Border(1).Height(20).Padding(4).Text("-"); + }); + + // ===== Shared Permissions ===== + col.Item().PaddingTop(0).Element(x => x.Background(Colors.Black).Padding(3)) + .Text("Shared Permissions").FontColor(Colors.White).Bold(); + + col.Item().Border(1).Padding(0).Element(x => SharedPermissionsTable(x, m)); + + // ===== Copier ===== + col.Item().PaddingTop(0).Element(x => x.Border(1).Padding(8)).Column(cc => + { + cc.Item().Row(rr => + { + rr.RelativeItem().Row(r1 => + { + r1.RelativeItem().Text("Copier Scanner"); + r1.RelativeItem().Text($"{Box(true)} Black"); + r1.RelativeItem().Text($"{Box(false)} Color"); + }); + + rr.RelativeItem().Row(r2 => + { + r2.RelativeItem().Text("Copier Printing"); + r2.RelativeItem().Text($"{Box(true)} Black"); + r2.RelativeItem().Text($"{Box(false)} Color"); + }); + }); + }); + }); + } + void SharedPermissionsTable(IContainer c, ItRequestReportModel m) + { + c.Table(t => + { + t.ColumnsDefinition(cols => + { + cols.ConstantColumn(18); // # + cols.RelativeColumn(6); // Share + cols.RelativeColumn(1); // Read + cols.RelativeColumn(1); // Write + cols.RelativeColumn(1); // Delete + cols.RelativeColumn(1); // Remove + }); + + // header band + t.Header(h => + { + h.Cell().Element(CellHead).Text(""); + h.Cell().Element(CellHead).Text("Shared Permissions"); + h.Cell().Element(CellHead).Text("Read"); + h.Cell().Element(CellHead).Text("Write"); + h.Cell().Element(CellHead).Text("Delete"); + h.Cell().Element(CellHead).Text("Remove"); + }); + + var rows = m.SharedPerms.Select((sp, i) => new + { + Index = i + 1, + sp.Share, + sp.R, + sp.W, + sp.D, + sp.Remove + }).ToList(); + + foreach (var r in rows) + { + t.Cell().Element(Cell).Text(r.Index.ToString()); + t.Cell().Element(Cell).Text(r.Share ?? ""); + t.Cell().Element(Cell).Text(Box(r.R)); + t.Cell().Element(Cell).Text(Box(r.W)); + t.Cell().Element(Cell).Text(Box(r.D)); + t.Cell().Element(Cell).Text(Box(r.Remove)); + } + + int start = rows.Count + 1; // no hardcoded row; if no data, start at 1 + + for (int i = start; i <= 6; i++) + { + t.Cell().Element(Cell).Text(i.ToString()); + t.Cell().Element(Cell).Text(""); + t.Cell().Element(Cell).Text(""); + t.Cell().Element(Cell).Text(""); + t.Cell().Element(Cell).Text(""); + t.Cell().Element(Cell).Text(""); + } + }); + } + + + #endregion + + #region SECTION A • OS Requirements + // ===================== SECTION A – Form Arrangement ===================== + + void FormArrangementBlock(IContainer c, ItRequestReportModel m) + { + // tiny helper: draws the vertical borders for each cell so it looks like 5 boxed panels + IContainer BoxCell(IContainer x, bool isLast) => + x.BorderLeft(1) + .BorderRight(isLast ? 1 : 0) // last cell closes the right border + .Padding(6) + .MinHeight(45); // keep all equal; adjust to taste + + c.Column(col => + { + // italic guide line + col.Item() + .PaddingBottom(4) + .Text("Form Arrangement: User → HOD → IT HOD → FINANCE HOD → CEO/CFO/COO") + .Italic(); + + // OUTER frame + col.Item().Border(1).Element(frame => + { + frame.Table(t => + { + // 5 equal columns + t.ColumnsDefinition(cols => + { + cols.RelativeColumn(1); + cols.RelativeColumn(1); + cols.RelativeColumn(1); + cols.RelativeColumn(1); + cols.RelativeColumn(1); + }); + + // local function to render a boxed panel + void Panel(int index0to4, string title, string name, string date) + { + bool last = index0to4 == 4; + t.Cell().Element(x => BoxCell(x, last)).Column(cc => + { + cc.Item().Text(title); + + // signature line + + + // Name / Date rows + cc.Item().PaddingTop(8).Row(r => + { + r.ConstantItem(40).Text("Name :"); + r.RelativeItem().Text(string.IsNullOrWhiteSpace(name) ? "" : name); + }); + cc.Item().Row(r => + { + r.ConstantItem(40).Text("Date :"); + r.RelativeItem().Text(string.IsNullOrWhiteSpace(date) ? "" : date); + }); + }); + } + + // build the 5 panels (plug in your resolved names/dates) + Panel(0, "Requested by:", m.RequestorName ?? "", F(m.SubmitDate) ?? ""); + Panel(1, "Approved by HOD:", m.HodApprovedBy ?? "", F(m.HodSubmitDate) ?? ""); + Panel(2, "Approved by Group IT HOD:", m.GitHodApprovedBy ?? "", F(m.GitHodSubmitDate) ?? ""); + Panel(3, "Supported by Finance HOD:", m.FinHodApprovedBy ?? "", F(m.FinHodSubmitDate) ?? ""); + Panel(4, "Reviewed & Approved by Management:", m.MgmtApprovedBy ?? "", F(m.MgmtSubmitDate) ?? ""); + }); + }); + }); + } + + #endregion + + #region SECTION A • Approvals + void Approvals(IContainer c, ItRequestReportModel m) + { + c.Column(col => + { + col.Item().Row(r => + { + r.RelativeItem().Column(cc => + { + cc.Item().Text("Requested by:"); + cc.Item().PaddingTop(12).Text("Name : MANDATORY"); + cc.Item().Row(rr => + { + rr.ConstantItem(40).Text("Date :"); + rr.RelativeItem().Border(1).Height(14).Text(F(m.SubmitDate)); + }); + }); + + r.RelativeItem().Column(cc => + { + cc.Item().Text("Approved by HOD:"); + cc.Item().PaddingTop(12).Text("Name :"); + cc.Item().Row(rr => + { + rr.ConstantItem(40).Text("Date :"); + rr.RelativeItem().Border(1).Height(16).Text(""); + }); + }); + }); + + col.Item().Row(r => + { + r.RelativeItem().Column(cc => + { + cc.Item().Text("Approved by Group IT HOD:"); + cc.Item().PaddingTop(12).Text("Name :"); + cc.Item().Row(rr => + { + rr.ConstantItem(40).Text("Date :"); + rr.RelativeItem().Border(1).Height(16).Text(""); + }); + }); + + r.RelativeItem().Column(cc => + { + cc.Item().Text("Supported by Finance HOD:"); + cc.Item().PaddingTop(12).Text("Name :"); + cc.Item().Row(rr => + { + rr.ConstantItem(40).Text("Date :"); + rr.RelativeItem().Border(1).Height(16).Text(""); + }); + }); + }); + + col.Item().Row(r => + { + r.RelativeItem().Column(cc => + { + cc.Item().Text("Reviewed & Approved by"); + cc.Item().Text("Management:"); + cc.Item().PaddingTop(12).Text("Name :"); + cc.Item().Row(rr => + { + rr.ConstantItem(40).Text("Date :"); + rr.RelativeItem().Border(1).Height(16).Text(""); + }); + }); + + r.RelativeItem().Text(""); + }); + }); + } + #endregion + + // ================= SECTION B ================= + #region SECTION B • Asset Information/ Acknowledgment + + void SectionB_TwoBlocks(IContainer c, ItRequestReportModel m) + { + c.Column(col => + { + // Title line exactly like screenshot (italic note on same line) + col.Item().Text(text => + { + text.Span("Section B ").Bold(); + text.Span("(To be completed by IT Staff only)").Italic(); + }); + + // ===== Block 1: Asset Information (left) + Remarks (right) ===== + col.Item().PaddingTop(4).Border(1).Element(block1 => + { + block1.Table(t => + { + t.ColumnsDefinition(cols => + { + cols.RelativeColumn(1); + cols.ConstantColumn(1); // skinny divider (we'll draw borders on cells) + cols.RelativeColumn(1); + }); + + // Header row with black bands + // Left header + t.Cell().Element(x => x.Background(Colors.Black).Padding(3).BorderRight(1)) + .Text("Asset Information").FontColor(Colors.White).Bold(); + // divider (invisible content, keeps structure) + t.Cell().BorderLeft(0).BorderRight(0).Text(""); + // Right header + t.Cell().Element(x => x.Background(Colors.Black).Padding(3)) + .Text("Remarks:-").FontColor(Colors.White).Bold(); + + // Content row (equal height because same table row) + // Left: asset info table + t.Cell().Element(x => x.BorderTop(1).BorderRight(1).Padding(0)) + .Element(x => SectionB_AssetInfoTable(x, m)); + + // divider + t.Cell().Border(0).Text(""); + + // Right: remarks box + t.Cell().Element(x => x.BorderTop(1).Padding(6).MinHeight(108)) + .Text(string.IsNullOrWhiteSpace(m.Remarks) ? "" : m.Remarks); + }); + }); + + // ===== Block 2: Requestor Acknowledgement (left) + Completed By (right) ===== + // ===== Block 2: Requestor Acknowledgement (left) + Completed By (right) ===== + col.Item().PaddingTop(6).Border(1).Element(block2 => + { + block2.Table(t => + { + t.ColumnsDefinition(cols => + { + cols.RelativeColumn(1); + cols.ConstantColumn(1); // divider + cols.RelativeColumn(1); + }); + + // Left pane (no signature line, compact height) + t.Cell().Element(x => x.Padding(8).BorderRight(1)).Column(cc => + { + cc.Item().Text("Requestor Acknowledgement:").Bold(); + cc.Item().PaddingTop(4).Row(r => + { + r.ConstantItem(44).Text("Name:"); + r.RelativeItem().Text(m.RequestorName ?? ""); + r.ConstantItem(44).Text("Date:"); + r.RelativeItem().Text(F(m.RequestorAcceptedAt)); + }); + }); + + // divider + t.Cell().Border(0).Text(""); + + // Right pane (no signature line) + t.Cell().Element(x => x.Padding(8)).Column(cc => + { + cc.Item().Text("Completed by:").Bold(); + cc.Item().PaddingTop(4).Row(r => + { + r.ConstantItem(44).Text("Name:"); + r.RelativeItem().Text(m.ItCompletedBy ?? ""); + r.ConstantItem(44).Text("Date:"); + r.RelativeItem().Text(F(m.ItAcceptedAt)); + }); + }); + }); + }); + + }); + } + + // Left-pane table used in Block 1 (matches your rows & borders) + void SectionB_AssetInfoTable(IContainer c, ItRequestReportModel m) + { + c.Table(t => + { + t.ColumnsDefinition(cols => + { + cols.RelativeColumn(2); // label + cols.RelativeColumn(2); // value box + }); + + void Row(string label, string value) + { + t.Cell().BorderBottom(1).Padding(6).Text(label); + t.Cell().BorderLeft(1).BorderBottom(1).Padding(3).Text(value ?? ""); + } + + // top row gets its own bottom borders; left cell also has right divider + Row("Asset No:", m.AssetNo); + Row("Machine ID:", m.MachineId); + Row("IP Add:", m.IpAddress); + Row("Wired Mac Add:", m.WiredMac); + Row("Wi-Fi Mac Add:", m.WifiMac); + Row("Dial-up Acc:", m.DialupAcc); + }); + } + + #endregion + } +} diff --git a/Areas/IT/Printing/ItRequestReportModel.cs b/Areas/IT/Printing/ItRequestReportModel.cs new file mode 100644 index 0000000..73f4477 --- /dev/null +++ b/Areas/IT/Printing/ItRequestReportModel.cs @@ -0,0 +1,79 @@ +namespace PSTW_CentralSystem.Areas.IT.Printing +{ + public class ItRequestReportModel + { + // Header/meta + public string DocumentNo { get; set; } = "GITRF_01"; + public string RevNo { get; set; } = ""; + public string DocPageNo { get; set; } = "1 of 1"; + public DateTime EffectiveDate { get; set; } = DateTime.Today; + + // Section A – Requestor snapshot + public string StaffName { get; set; } = ""; + public string CompanyName { get; set; } = ""; + public string DepartmentName { get; set; } = ""; + public string Designation { get; set; } = ""; + public string Location { get; set; } = ""; + public string EmploymentStatus { get; set; } = ""; + public DateTime? ContractEndDate { get; set; } + public DateTime RequiredDate { get; set; } + public string PhoneExt { get; set; } = ""; + + // Captured lists (kept for other sections) + public List Hardware { get; set; } = new(); + public string? Justification { get; set; } + public List Emails { get; set; } = new(); + public List OsRequirements { get; set; } = new(); + public List<(string Bucket, string Name, string? Other, string? Notes)> Software { get; set; } = new(); + public List<(string Share, bool R, bool W, bool D, bool Remove)> SharedPerms { get; set; } = new(); + + // ===== NEW: Hardware purposes (left column) ===== + public bool HwPurposeNewRecruitment { get; set; } + public bool HwPurposeReplacement { get; set; } + public bool HwPurposeAdditional { get; set; } + + // ===== NEW: Hardware selections (right column) ===== + public bool HwDesktopAllIn { get; set; } + public bool HwNotebookAllIn { get; set; } + public bool HwDesktopOnly { get; set; } + public bool HwNotebookOnly { get; set; } + public bool HwNotebookBattery { get; set; } + public bool HwPowerAdapter { get; set; } + public bool HwMouse { get; set; } + public bool HwExternalHdd { get; set; } + public string? HwOtherText { get; set; } + + // Section B – IT staff + public string AssetNo { get; set; } = ""; + public string MachineId { get; set; } = ""; + public string IpAddress { get; set; } = ""; + public string WiredMac { get; set; } = ""; + public string WifiMac { get; set; } = ""; + public string DialupAcc { get; set; } = ""; + public string Remarks { get; set; } = ""; + + // Acceptance + public string RequestorName { get; set; } = ""; + public DateTime? RequestorAcceptedAt { get; set; } + public string ItCompletedBy { get; set; } = ""; + public DateTime? ItAcceptedAt { get; set; } + + // Status + public int ItRequestId { get; set; } + public int StatusId { get; set; } + public string OverallStatus { get; set; } = ""; + public DateTime SubmitDate { get; set; } + + // Approval Flow dates + public DateTime? HodSubmitDate { get; set; } + public DateTime? GitHodSubmitDate { get; set; } + public DateTime? FinHodSubmitDate { get; set; } + public DateTime? MgmtSubmitDate { get; set; } + + // Approvers + public string HodApprovedBy { get; set; } + public string GitHodApprovedBy { get; set; } + public string FinHodApprovedBy { get; set; } + public string MgmtApprovedBy { get; set; } + } +} diff --git a/Areas/IT/Views/ApprovalDashboard/Admin.cshtml b/Areas/IT/Views/ApprovalDashboard/Admin.cshtml new file mode 100644 index 0000000..3b41ce2 --- /dev/null +++ b/Areas/IT/Views/ApprovalDashboard/Admin.cshtml @@ -0,0 +1,473 @@ +@{ + ViewData["Title"] = "IT Request Assignments"; + Layout = "~/Views/Shared/_Layout.cshtml"; +} + + + + + + +
+ + +
+
+
+
+ + +
+
+
+
{{ error }}
+
Loading…
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Flow IDFlow NameHODGroup IT HODFIN HODMGMTAction
{{ f.itApprovalFlowId }}{{ f.flowName }}{{ f.hodUserId ? `${f.hodUserId} — ${resolveUserName(f.hodUserId)}` : '-' }}{{ f.groupItHodUserId ? `${f.groupItHodUserId} — ${resolveUserName(f.groupItHodUserId)}` : '-' }}{{ f.finHodUserId ? `${f.finHodUserId} — ${resolveUserName(f.finHodUserId)}` : '-' }}{{ f.mgmtUserId ? `${f.mgmtUserId} — ${resolveUserName(f.mgmtUserId)}` : '-' }} +
+ + +
+
No flows yet. Click “New Flow”.
+
+ +
+ + Heads up: /ItRequestAPI/create uses the first flow in the DB. Make sure one exists w/ approvers. + +
+
+
+ + +
+
+
IT Team Members
+ +
+ +
+
+ Select existing users to mark them as IT Team (they can edit Section B + do IT acceptance). +
+ +
+ +
+
+ + +
+ +
+ +
+ No users match your search. +
+
+
+ + +
+
+ Selected ({{ selectedUsers.length }}) + +
+ +
+ + {{ u.name }} + + +
Nobody selected yet.
+
+
+
+
+
+ + + + +
+ + diff --git a/Areas/IT/Views/ApprovalDashboard/Approval.cshtml b/Areas/IT/Views/ApprovalDashboard/Approval.cshtml new file mode 100644 index 0000000..ec06f97 --- /dev/null +++ b/Areas/IT/Views/ApprovalDashboard/Approval.cshtml @@ -0,0 +1,475 @@ +@{ + ViewData["Title"] = "IT Request Approval Board"; + Layout = "~/Views/Shared/_Layout.cshtml"; +} + + + + +
+

+

Manage approvals and track Section B progress by month.

+ + +
+
+ + +
+
+ + +
+
+ +
{{ error }}
+
Loading…
+ + + + + + + + + + + + + +
+ + diff --git a/Areas/IT/Views/ApprovalDashboard/Create.cshtml b/Areas/IT/Views/ApprovalDashboard/Create.cshtml new file mode 100644 index 0000000..b0b2629 --- /dev/null +++ b/Areas/IT/Views/ApprovalDashboard/Create.cshtml @@ -0,0 +1,825 @@ +@{ + ViewData["Title"] = "New IT Request"; + Layout = "~/Views/Shared/_Layout.cshtml"; +} + + + + + +
+
+

+
Stages: HOD → Group IT HOD → Finance HOD → Management
+
+ + +
+
+
Requester Details
+ These fields are snapshotted at submission +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
{{ validation.requiredDate }}
+
+
+
+
+ + +
+
+
Hardware Requirements
+
+ None selected + {{ hardwareCount }} selected +
+
+
+
+
+ + +
{{ validation.hardwarePurpose }}
+ + + +
+ +
+
+ +
All-inclusive toggles will auto-select sensible accessories
+
+
+
+ + +
+
+
+ + +
+
+
+
+
+ + +
+
+
Email
+
+ Enter proposed address(es) without @@domain + +
+
+
+
+
Proposed Address (without @@domain)
+
+
+ +
+ +
+
+
No email rows
+
+
+
+
+ + +
+
+
Operating System Requirements
+ +
+
+
+
Requirement
+
+ + +
+
+
No OS requirements
+
+
+
+
+ + +
+
+
Software
+ Tick to include; use Others to specify anything not listed +
+
+
+
+
General Software
+
+ + +
+
+ + +
+
+ +
+
Utility Software
+
+ + +
+
+ + +
+
+ +
+
Custom Software
+ + +
+
+
+
+ + +
+
+
Shared Permissions
+
+ Max 6 entries + +
+
+
+
+
Share Name & Permissions
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
No shared permissions
+
+
+
{{ validation.sharedPerms }}
+
+
+ + +
+
+ + + {{ model.requiredDate ? 'Required date set' : 'Required date missing' }} + + Hardware purpose required + Hardware purpose ok + Max 6 permissions +
+ + + +
+
+ + + +@section Scripts { + + + +} diff --git a/Areas/IT/Views/ApprovalDashboard/Edit.cshtml b/Areas/IT/Views/ApprovalDashboard/Edit.cshtml new file mode 100644 index 0000000..6a4f2bc --- /dev/null +++ b/Areas/IT/Views/ApprovalDashboard/Edit.cshtml @@ -0,0 +1,698 @@ +@{ + ViewData["Title"] = "Edit IT Request"; + Layout = "~/Views/Shared/_Layout.cshtml"; +} + + + + + +
+
+
+ {{ isEditable ? 'Editable' : 'Locked' }} +
+ + +
+
+
+ + +
+
+
Requester Details
+ These fields are snapshotted at submission +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
{{ validation.requiredDate }}
+
+
+
+
+ + +
+
+
Hardware Requirements
+
+
+
+
+ + +
{{ validation.hardwarePurpose }}
+ + + +
+ +
+
+ +
All-inclusive toggles will auto-select sensible accessories
+
+
+
+ + +
+
+
+ + +
+
+
+
+
+ + +
+
+
Email
+
+ Enter proposed address(es) without @@domain + +
+
+
+
+
Proposed Address (without @@domain)
+
+
+ +
+ +
+
+
No email rows
+
+
+
+
+ + +
+
+
Operating System Requirements
+ +
+
+
+
Requirement
+
+ + +
+
+
No OS requirements
+
+
+
+
+ + +
+
+
Software
+ Tick to include; use Others to specify anything not listed +
+
+
+
+
General Software
+
+ + +
+
+ + +
+
+ +
+
Utility Software
+
+ + +
+
+ + +
+
+ +
+
Custom Software
+ + +
+
+
+
+ + +
+
+
Shared Permissions
+
+ Max 6 entries + +
+
+
+
+
Share Name & Permissions
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
No shared permissions
+
+
+
{{ validation.sharedPerms }}
+
+
+ + +
+ + + +
+
+ +@section Scripts { + +} diff --git a/Areas/IT/Views/ApprovalDashboard/MyRequests.cshtml b/Areas/IT/Views/ApprovalDashboard/MyRequests.cshtml new file mode 100644 index 0000000..2d747cd --- /dev/null +++ b/Areas/IT/Views/ApprovalDashboard/MyRequests.cshtml @@ -0,0 +1,557 @@ +@{ + ViewData["Title"] = "My IT Requests"; + Layout = "~/Views/Shared/_Layout.cshtml"; +} + + + + + +
+

+ + +
+
+ + +
+
+ + +
+
+ +
{{ error }}
+
Loading…
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DepartmentCompanyRequired DateDate SubmittedAction
{{ r.departmentName }}{{ r.companyName }}{{ fmtDate(r.requiredDate) }}{{ fmtDateTime(r.submitDate) }} +
+ + + + + +
+
No {{ activeTab.toLowerCase() }} requests
+ + +
+ + Showing {{ (currentPage - 1) * itemsPerPage + 1 }} – {{ Math.min(currentPage * itemsPerPage, filteredData.length) }} + of {{ filteredData.length }} + + +
+
Rows
+ +
+ + +
+
+
+
+ + +
+
+ Section B + Requests with overall status Approved/Completed +
+ + +
+
+ Show: + + + + +
+
+ You have {{ sbCount.need }} Section B {{ sbCount.need===1 ? 'item' : 'items' }} that need your acceptance. +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DepartmentCompanySection B StatusAction
{{ r.departmentName }}{{ r.companyName }} + + + + + + + + +
+ + + + + + + + + +
+
No Section B items in this view
+ + +
+ + Showing {{ (sectionBPageIndex - 1) * itemsPerPage + 1 }} – {{ Math.min(sectionBPageIndex * itemsPerPage, sectionBFiltered.length) }} + of {{ sectionBFiltered.length }} + + +
+ + +
+
+
+
+ +@section Scripts { + +} diff --git a/Areas/IT/Views/ApprovalDashboard/RequestReview.cshtml b/Areas/IT/Views/ApprovalDashboard/RequestReview.cshtml new file mode 100644 index 0000000..10ed81d --- /dev/null +++ b/Areas/IT/Views/ApprovalDashboard/RequestReview.cshtml @@ -0,0 +1,632 @@ +@{ + ViewData["Title"] = "IT Request Review"; + Layout = "~/Views/Shared/_Layout.cshtml"; +} + + + + + +
+ +
+

+

+
+ + + {{ overallChip.text }} + +
+
+ + +
+
+
Requester Info
+ Submitted: {{ formatDate(userInfo.submitDate) }} +
+
+
+
+
+ Name
+ {{ userInfo.staffName || '—' }} +
+
+
+ Department
+ {{ userInfo.departmentName || '—' }} +
+
+
+ Company
+ {{ userInfo.companyName || '—' }} +
+
+
+ Designation
+ {{ userInfo.designation || '—' }} +
+
+
+
+
+ + +
+
+
Hardware Requested
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
CategoryPurposeJustificationOther
{{ item.category }}{{ item.purpose }}{{ item.justification }}{{ item.otherDescription }}
+ + No hardware requested +
+
+
+
+ + +
+
+
Email Requests
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + +
PurposeProposed Address
{{ item.purpose }}{{ item.proposedAddress }}
+ + No email requests +
+
+
+
+ + +
+
+
Operating System Requirements
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
Requirement
{{ item.requirementText }}
+ + No OS requirements +
+
+
+
+ + +
+
+
Software Requested
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
BucketNameOtherNotes
{{ item.bucket }}{{ item.name }}{{ item.otherName }}{{ item.notes }}
+ + No software requested +
+
+
+
+ + +
+
+
Shared Folder / Permission Requests
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
Share NameReadWriteDeleteRemove
{{ item.shareName }}{{ item.canRead ? 'Yes' : 'No' }}{{ item.canWrite ? 'Yes' : 'No' }}{{ item.canDelete ? 'Yes' : 'No' }}{{ item.canRemove ? 'Yes' : 'No' }}
+ + No shared permissions requested +
+
+
+
+ + +
+
+
Approval Trail
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StageStatusDate
HOD{{ status.hodStatus || '—' }}{{ formatDate(status.hodSubmitDate) || '—' }}
Group IT HOD{{ status.gitHodStatus || '—' }}{{ formatDate(status.gitHodSubmitDate) || '—' }}
Finance HOD{{ status.finHodStatus || '—' }}{{ formatDate(status.finHodSubmitDate) || '—' }}
Management{{ status.mgmtStatus || '—' }}{{ formatDate(status.mgmtSubmitDate) || '—' }}
+
+ + +
+ + +
+
+
+
+ + diff --git a/Areas/IT/Views/ApprovalDashboard/SectionB.cshtml b/Areas/IT/Views/ApprovalDashboard/SectionB.cshtml new file mode 100644 index 0000000..79e6165 --- /dev/null +++ b/Areas/IT/Views/ApprovalDashboard/SectionB.cshtml @@ -0,0 +1,360 @@ +@{ + ViewData["Title"] = "IT Section B – Asset Information"; + Layout = "~/Views/Shared/_Layout.cshtml"; + // Expect ?statusId=123 +} + + + + + +
+ +
+
Section B – Asset Information
+
+ Overall: {{ meta.overallStatus || '—' }} + Requestor: {{ meta.requestorName || '—' }} + You are IT + + {{ meta.sectionBSent ? 'Sent' : 'Draft' }} + +
+
+ +
{{ error }}
+
Loading…
+ + +
+ Section B is available only after the request is Approved. Current status: {{ meta.overallStatus || '—' }}. +
+ + +
+ +@section Scripts{ + +} diff --git a/Areas/IT/Views/ApprovalDashboard/SectionBEdit.cshtml b/Areas/IT/Views/ApprovalDashboard/SectionBEdit.cshtml new file mode 100644 index 0000000..9aa5a16 --- /dev/null +++ b/Areas/IT/Views/ApprovalDashboard/SectionBEdit.cshtml @@ -0,0 +1,321 @@ +@{ + ViewData["Title"] = "Edit Section B – Asset Information"; + Layout = "~/Views/Shared/_Layout.cshtml"; + // ?statusId=123 +} + + + + + +
+ +
+
Edit Section B – Asset Information
+
+ Overall: {{ meta.overallStatus || '—' }} + Requestor: {{ meta.requestorName || '—' }} + You are IT + + {{ meta.sectionBSent ? 'Sent' : 'Draft' }} + +
+
+ +
{{ error }}
+
Loading…
+ + +
+ Section B is available only after the request is Approved. Current status: {{ meta.overallStatus || '—' }}. +
+ + +
+ +@section Scripts{ + +} diff --git a/Controllers/API/BookingsAPI.cs b/Controllers/API/BookingsAPI.cs new file mode 100644 index 0000000..0258034 --- /dev/null +++ b/Controllers/API/BookingsAPI.cs @@ -0,0 +1,876 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using PSTW_CentralSystem.Areas.Bookings.Models; +using PSTW_CentralSystem.DBContext; +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PSTW_CentralSystem.Controllers.API +{ + [ApiController] + [Route("api/BookingsApi")] + [Authorize] // Require auth by default + public class BookingsAPIController : ControllerBase + { + private readonly CentralSystemContext _db; + private readonly ILogger _logger; + private static readonly TimeZoneInfo AppTz = TimeZoneInfo.FindSystemTimeZoneById("Asia/Kuala_Lumpur"); + + public BookingsAPIController(CentralSystemContext db, ILogger logger) + { + _db = db; _logger = logger; + } + + private int? GetCurrentUserId() + { + var idStr = User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + return int.TryParse(idStr, out var id) ? id : (int?)null; + } + + private bool IsManager() + { + var me = GetCurrentUserId(); + if (me is null) return false; + // Dynamic manager list (booking_managers) + return _db.BookingManager.AsNoTracking() + .Any(x => x.UserId == me.Value && x.IsActive); + } + + + private bool CanModify(Booking b, int? currentUserId) + { + if (b is null || currentUserId is null) return false; + if (IsManager()) return true; // Managers: can modify any + // Owner OR target may modify + return b.RequestedByUserId == currentUserId || b.TargetUserId == currentUserId; + } + + #region Undo helpers + private static bool IsTruthy(string? s) + { + if (string.IsNullOrWhiteSpace(s)) return false; + var v = s.Trim(); + return v == "1" + || v.Equals("true", StringComparison.OrdinalIgnoreCase) + || v.Equals("yes", StringComparison.OrdinalIgnoreCase) + || v.Equals("y", StringComparison.OrdinalIgnoreCase) + || v.Equals("on", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsSameLocalDate(DateTime aUtc, DateTime bUtc, TimeZoneInfo tz) + { + var a = TimeZoneInfo.ConvertTimeFromUtc(aUtc, tz).Date; + var b = TimeZoneInfo.ConvertTimeFromUtc(bUtc, tz).Date; + return a == b; + } + + private bool ShouldUndo(System.Text.Json.JsonElement? body) + { + // query string: /api/BookingsApi?action=cancel&id=1&undo=1 + if (Request.Query.TryGetValue("undo", out var qv)) + { + string? s = qv.Count > 0 ? qv[0].ToString() : null; + if (IsTruthy(s)) return true; + } + + // JSON body: { "undo": true } or { "uncancel": true } or { "restore": true } + if (body.HasValue && body.Value.ValueKind == System.Text.Json.JsonValueKind.Object) + { + var root = body.Value; + if ((root.TryGetProperty("undo", out var a) && a.ValueKind == System.Text.Json.JsonValueKind.True) + || (root.TryGetProperty("uncancel", out var b) && b.ValueKind == System.Text.Json.JsonValueKind.True) + || (root.TryGetProperty("unCancel", out var c) && c.ValueKind == System.Text.Json.JsonValueKind.True) + || (root.TryGetProperty("restore", out var d) && d.ValueKind == System.Text.Json.JsonValueKind.True)) + return true; + } + return false; + } + #endregion + + private static string? GetBodyNote(in JsonElement root) + => root.TryGetProperty("note", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString() : null; + + // ------------------- Helpers ------------------- + private static DateTime CoerceToUtc(DateTime dt) + { + return dt.Kind switch + { + DateTimeKind.Utc => dt, + DateTimeKind.Unspecified => DateTime.SpecifyKind(dt, DateTimeKind.Utc), + _ => dt.ToUniversalTime() + }; + } + + private static bool IsSameUtcDate(DateTime a, DateTime b) => a.Date == b.Date; + + private static JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true + }; + + private static DateTime SnapToNearest30(DateTime utc) + { + var minutes = utc.Minute; + var baseTime = new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, DateTimeKind.Utc); + if (minutes < 15) return baseTime; // round down + if (minutes < 45) return baseTime.AddMinutes(30); // round to :30 + return baseTime.AddHours(1); // round up to next hour + } + + // ------------------- DTOs ------------------- + public record CreateBookingDto( + string Title, + string? Purpose, + [property: JsonPropertyName("description")] string? Description, // alias + int RoomId, + DateTime StartUtc, + DateTime EndUtc, + int RequestedByUserId, + int? TargetUserId, + string? Note, + [property: JsonPropertyName("notes")] string? Notes // alias + ); + + public record UpdateBookingDto( + string? Title, + string? Purpose, + [property: JsonPropertyName("description")] string? Description, // alias + int? RoomId, + DateTime? StartUtc, + DateTime? EndUtc, + string? Note, + [property: JsonPropertyName("notes")] string? Notes // alias + ); + + public record CreateRoomDto(string RoomName, string? LocationCode, int? Capacity, bool IsActive = true); + public record UpdateRoomDto(string? RoomName, string? LocationCode, int? Capacity, bool? IsActive); + + + // GET /api/BookingsApi/users + [HttpGet("users")] + public async Task UsersAsync() + { + var list = await _db.Users + .AsNoTracking() + .OrderBy(u => u.FullName) + .Select(u => new { id = u.Id, name = u.FullName, email = u.Email }) + .ToListAsync(); + return Ok(list); + } + + // GET /api/BookingsApi/managers -> [1,2,3] + [HttpGet("managers")] + public async Task GetManagersAsync() + { + var ids = await _db.BookingManager + .AsNoTracking() + .Where(x => x.IsActive) + .Select(x => x.UserId) + .ToListAsync(); + return Ok(ids); + } + + public class SaveManagersDto { public List? UserIds { get; set; } } + + // POST /api/BookingsApi/managers body: { "userIds":[1,2,3] } + [HttpPost("managers")] + public async Task SaveManagersAsync([FromBody] SaveManagersDto dto) + { + if (dto?.UserIds is null) return BadRequest("userIds required."); + // Only an existing manager can edit the list (bootstrap by seeding one row) + if (!IsManager()) return Unauthorized("Managers only."); + + var now = DateTime.UtcNow; + var me = GetCurrentUserId(); + + // Soft-reset approach: mark all inactive then upsert selected as active + var all = await _db.BookingManager.ToListAsync(); + foreach (var bm in all) bm.IsActive = false; + + var selected = new HashSet(dto.UserIds.Where(id => id > 0)); + + foreach (var uid in selected) + { + var existing = all.FirstOrDefault(x => x.UserId == uid); + if (existing is null) + { + _db.BookingManager.Add(new BookingManager + { + UserId = uid, + IsActive = true, + CreatedUtc = now, + CreatedByUserId = me + }); + } + else + { + existing.IsActive = true; + } + } + + await _db.SaveChangesAsync(); + return Ok(new { saved = true, count = selected.Count }); + } + + + + // ========================================================= + // ========================= GET =========================== + // ========================================================= + // - Bookings list: GET /api/BookingsApi + // - Booking details: GET /api/BookingsApi?id=123 + // - Lookups (rooms+users) GET /api/BookingsApi?lookups=1 + // - Rooms list: GET /api/BookingsApi?scope=rooms[&includeInactive=true] + + + + [HttpGet] + public async Task GetAsync( + [FromQuery] int? id, + [FromQuery] int? lookups, + [FromQuery] string? scope, + [FromQuery] bool includeInactive = true, + [FromQuery] string? search = null, + [FromQuery] string? status = null, + [FromQuery] DateTime? from = null, + [FromQuery] DateTime? to = null, + [FromQuery] int? roomId = null, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 450, + [FromQuery] int? userId = null, + [FromQuery] int? companyId = null, + [FromQuery] int? departmentId = null + ) + { + // ROOMS LIST (admin tooling / open to authenticated) + if (string.Equals(scope, "rooms", StringComparison.OrdinalIgnoreCase)) + { + var rq = _db.Rooms.AsNoTracking().AsQueryable(); + if (!includeInactive) rq = rq.Where(r => r.IsActive); + var rooms = await rq + .OrderBy(r => r.RoomName) + .Select(r => new + { + roomId = r.RoomId, + roomName = r.RoomName, + locationCode = r.LocationCode, + capacity = r.Capacity, + isActive = r.IsActive + }) + .ToListAsync(); + return Ok(rooms); + } + + // ---------- CALENDAR LOOKUPS (everyone sees the full lists) ---------- + // GET /api/BookingsApi?scope=calendar&lookups=1 + if (string.Equals(scope, "calendar", StringComparison.OrdinalIgnoreCase) && lookups == 1) + { + var roomsAll = await _db.Rooms + .AsNoTracking() + .Where(r => r.IsActive) + .OrderBy(r => r.RoomName) + .Select(r => new { roomId = r.RoomId, roomName = r.RoomName }) + .ToListAsync(); + + var usersAll = await _db.Users + .AsNoTracking() + .OrderBy(u => u.FullName) + .Select(u => new { u.Id, UserName = u.FullName, u.Email }) + .ToListAsync(); + + return Ok(new { rooms = roomsAll, users = usersAll }); + } + + // ---------- CALENDAR LIST (everyone sees all bookings) ---------- + // GET /api/BookingsApi?scope=calendar&from=...&to=... + if (string.Equals(scope, "calendar", StringComparison.OrdinalIgnoreCase) && !id.HasValue && lookups != 1) + { + var qCal = _db.Bookings.AsNoTracking().AsQueryable(); + + if (!string.IsNullOrWhiteSpace(search)) + qCal = qCal.Where(x => x.Title.Contains(search)); + + if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse(status, true, out var stCal)) + qCal = qCal.Where(x => x.CurrentStatus == stCal); + + DateTime? fromUtcCal = from.HasValue ? CoerceToUtc(from.Value) : null; + DateTime? toUtcCal = to.HasValue ? CoerceToUtc(to.Value) : null; + + if (fromUtcCal.HasValue) qCal = qCal.Where(x => x.EndUtc > fromUtcCal.Value); + if (toUtcCal.HasValue) qCal = qCal.Where(x => x.StartUtc < toUtcCal.Value); + + if (roomId.HasValue) qCal = qCal.Where(x => x.RoomId == roomId.Value); + if (userId.HasValue && userId.Value > 0) + qCal = qCal.Where(x => x.RequestedByUserId == userId.Value || x.TargetUserId == userId.Value); + + if (companyId.HasValue && companyId.Value > 0) + qCal = qCal.Where(x => x.CompanyId == companyId.Value); + + if (departmentId.HasValue && departmentId.Value > 0) + qCal = qCal.Where(x => x.DepartmentId == departmentId.Value); + + var list = await qCal + .OrderBy(x => x.StartUtc) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(b => new + { + id = b.BookingId, + roomId = b.RoomId, + requestedByUserId = b.RequestedByUserId, + targetUserId = b.TargetUserId, + title = b.Title, + description = b.Purpose, + startUtc = DateTime.SpecifyKind(b.StartUtc, DateTimeKind.Utc), + endUtc = DateTime.SpecifyKind(b.EndUtc, DateTimeKind.Utc), + status = b.CurrentStatus.ToString(), + note = b.Note + }) + .ToListAsync(); + + return Ok(list); + } + + // LOOKUPS PACK + if (lookups == 1) + { + var rooms = await _db.Rooms + .AsNoTracking() + .Where(r => r.IsActive) + .OrderBy(r => r.RoomName) + .Select(r => new { roomId = r.RoomId, roomName = r.RoomName }) + .ToListAsync(); + + object usersPayload; + if (IsManager()) + { + usersPayload = await _db.Users + .AsNoTracking() + .OrderBy(u => u.FullName) + .Select(u => new { u.Id, UserName = u.FullName, u.Email }) + .ToListAsync(); + } + else + { + var me = GetCurrentUserId(); + usersPayload = (me is int myId) + ? await _db.Users.AsNoTracking() + .Where(u => u.Id == myId) + .Select(u => new { u.Id, UserName = u.FullName, u.Email }) + .ToListAsync() + : new object[0]; + } + + return Ok(new { rooms, users = usersPayload }); + } + + // BOOKING DETAILS + if (id.HasValue) + { + var b = await _db.Bookings.AsNoTracking().FirstOrDefaultAsync(x => x.BookingId == id.Value); + if (b is null) return NotFound(); + + var me = GetCurrentUserId(); + if (!IsManager() && me != b.RequestedByUserId && me != b.TargetUserId) + return Unauthorized("Not allowed to view this booking."); + + return Ok(new + { + id = b.BookingId, + roomId = b.RoomId, + requestedByUserId = b.RequestedByUserId, + title = b.Title, + description = b.Purpose, + startUtc = DateTime.SpecifyKind(b.StartUtc, DateTimeKind.Utc), + endUtc = DateTime.SpecifyKind(b.EndUtc, DateTimeKind.Utc), + status = b.CurrentStatus.ToString(), + note = b.Note + }); + } + + // BOOKINGS LIST + var q = _db.Bookings.AsNoTracking().AsQueryable(); + + if (!string.IsNullOrWhiteSpace(search)) q = q.Where(x => x.Title.Contains(search)); + + if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse(status, true, out var st)) + q = q.Where(x => x.CurrentStatus == st); + + DateTime? fromUtc = from.HasValue ? CoerceToUtc(from.Value) : null; + DateTime? toUtc = to.HasValue ? CoerceToUtc(to.Value) : null; + + if (fromUtc.HasValue) q = q.Where(x => x.EndUtc > fromUtc.Value); + if (toUtc.HasValue) q = q.Where(x => x.StartUtc < toUtc.Value); + + if (roomId.HasValue) q = q.Where(x => x.RoomId == roomId.Value); + if (userId.HasValue && userId.Value > 0) + q = q.Where(x => x.RequestedByUserId == userId.Value || x.TargetUserId == userId.Value); + + if (companyId.HasValue && companyId.Value > 0) + q = q.Where(x => x.CompanyId == companyId.Value); + + if (departmentId.HasValue && departmentId.Value > 0) + q = q.Where(x => x.DepartmentId == departmentId.Value); + + // RBAC: non-managers only see their own + var meList = GetCurrentUserId(); + if (!IsManager()) + { + if (meList is int myId) + q = q.Where(x => x.RequestedByUserId == myId || x.TargetUserId == myId); + else + return Unauthorized(); + } + + var items = await q.OrderBy(x => x.StartUtc) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(b => new + { + id = b.BookingId, + roomId = b.RoomId, + requestedByUserId = b.RequestedByUserId, + title = b.Title, + description = b.Purpose, + startUtc = DateTime.SpecifyKind(b.StartUtc, DateTimeKind.Utc), + endUtc = DateTime.SpecifyKind(b.EndUtc, DateTimeKind.Utc), + status = b.CurrentStatus.ToString(), + note = b.Note + }) + .ToListAsync(); + + return Ok(items); + } + + // ========================================================= + // ======================== POST =========================== + // ========================================================= + // - Create booking: POST /api/BookingsApi + // - Cancel/Un-cancel: POST /api/BookingsApi?action=cancel&id=123 (body: { "undo": true }) + // - Create room: POST /api/BookingsApi?scope=rooms + [HttpPost] + public async Task PostAsync( + [FromQuery] string? action, + [FromQuery] int? id, + [FromQuery] string? scope, + [FromBody] System.Text.Json.JsonElement? body // nullable is fine + ) + { + var json = body?.ToString() ?? "{}"; + + // ROOMS: CREATE (Managers only) + if (string.Equals(scope, "rooms", StringComparison.OrdinalIgnoreCase)) + { + if (!IsManager()) return Unauthorized("Managers only."); + + var dto = JsonSerializer.Deserialize(json, JsonOpts); + if (dto is null || string.IsNullOrWhiteSpace(dto.RoomName)) + return BadRequest("RoomName is required."); + + var exists = await _db.Rooms.AnyAsync(r => r.RoomName == dto.RoomName); + if (exists) return Conflict("A room with this name already exists."); + + var room = new Room + { + RoomName = dto.RoomName.Trim(), + LocationCode = string.IsNullOrWhiteSpace(dto.LocationCode) ? null : dto.LocationCode.Trim(), + Capacity = dto.Capacity, + IsActive = dto.IsActive + }; + _db.Rooms.Add(room); + await _db.SaveChangesAsync(); + return Ok(new { roomId = room.RoomId }); + } + + // BOOKING: APPROVE (Manager only) + if (string.Equals(action, "approve", StringComparison.OrdinalIgnoreCase)) + { + if (!id.HasValue || id <= 0) return BadRequest("Missing id."); + if (!IsManager()) + return Unauthorized("Not authorized to approve bookings."); + + var b = await _db.Bookings.FirstOrDefaultAsync(x => x.BookingId == id.Value); + if (b is null) return NotFound(); + + if (b.CurrentStatus == BookingStatus.Cancelled) + return Conflict("Cannot approve a cancelled booking."); + if (b.CurrentStatus == BookingStatus.Approved) + return Ok(new { id = b.BookingId, status = b.CurrentStatus.ToString() }); + + // prevent overlap with other APPROVED bookings + var overlap = await _db.Bookings.AsNoTracking().AnyAsync(x => + x.RoomId == b.RoomId && + x.BookingId != b.BookingId && + x.CurrentStatus == BookingStatus.Approved && + x.StartUtc < b.EndUtc && b.StartUtc < x.EndUtc); + + if (overlap) return Conflict("Cannot approve: time slot overlaps an approved booking."); + + b.CurrentStatus = BookingStatus.Approved; + b.LastUpdatedUtc = DateTime.UtcNow; + await _db.SaveChangesAsync(); + return Ok(new { id = b.BookingId, status = b.CurrentStatus.ToString() }); + } + + // BOOKING: REJECT / UN-REJECT (Manager only; undo -> Pending) + if (string.Equals(action, "reject", StringComparison.OrdinalIgnoreCase)) + { + if (!id.HasValue || id <= 0) return BadRequest("Missing id."); + if (!IsManager()) + return Unauthorized("Not authorized to reject bookings."); + + var b = await _db.Bookings.FirstOrDefaultAsync(x => x.BookingId == id.Value); + if (b is null) return NotFound(); + + bool undo = ShouldUndo(body); + + if (!undo) + { + if (b.CurrentStatus == BookingStatus.Cancelled) + return Conflict("Cannot reject a cancelled booking."); + if (body.HasValue && body.Value.ValueKind == JsonValueKind.Object) + b.Note = GetBodyNote(body.Value); + b.CurrentStatus = BookingStatus.Rejected; + } + else + { + if (b.CurrentStatus != BookingStatus.Rejected) + return Conflict("Booking is not rejected."); + b.CurrentStatus = BookingStatus.Pending; + } + + b.LastUpdatedUtc = DateTime.UtcNow; + await _db.SaveChangesAsync(); + return Ok(new { id = b.BookingId, status = b.CurrentStatus.ToString() }); + } + + // ----- BOOKING: CANCEL / UN-CANCEL (Owner or Manager) ------------------- + if (string.Equals(action, "cancel", StringComparison.OrdinalIgnoreCase)) + { + if (!id.HasValue || id <= 0) return BadRequest("Missing id."); + + var b = await _db.Bookings.FirstOrDefaultAsync(x => x.BookingId == id.Value); + if (b is null) return NotFound(); + + var me = GetCurrentUserId(); + if (!CanModify(b, me)) + return Unauthorized("Only the creator or a manager can cancel/un-cancel this booking."); + if (b.CurrentStatus == BookingStatus.Rejected) + return Conflict("Rejected bookings cannot be cancelled or un-cancelled."); + + bool undo = ShouldUndo(body); + + if (!undo) + { + if (b.CurrentStatus != BookingStatus.Cancelled) + { + b.CurrentStatus = BookingStatus.Cancelled; + b.LastUpdatedUtc = DateTime.UtcNow; + await _db.SaveChangesAsync(); + } + return Ok(new { id = b.BookingId, status = b.CurrentStatus.ToString() }); + } + else + { + if (b.CurrentStatus != BookingStatus.Cancelled) + return Conflict("Booking is not cancelled."); + + var overlap = await _db.Bookings.AsNoTracking().AnyAsync(x => + x.RoomId == b.RoomId && + x.BookingId != b.BookingId && + (x.CurrentStatus == BookingStatus.Pending || x.CurrentStatus == BookingStatus.Approved) && + x.StartUtc < b.EndUtc && b.StartUtc < x.EndUtc); + + if (overlap) + return Conflict("Cannot un-cancel: time slot now overlaps another booking."); + + b.CurrentStatus = BookingStatus.Pending; + b.LastUpdatedUtc = DateTime.UtcNow; + await _db.SaveChangesAsync(); + return Ok(new { id = b.BookingId, status = b.CurrentStatus.ToString() }); + } + } + + // ------------------ BOOKING: CREATE ------------------------- + var create = JsonSerializer.Deserialize(json, JsonOpts); + if (create is null) return BadRequest("Invalid payload."); + + // For non-managers, force ownership to the current user + var meCreate = GetCurrentUserId(); + if (!IsManager()) + { + if (meCreate is null) return Unauthorized(); + create = create with + { + RequestedByUserId = meCreate.Value, + TargetUserId = create.TargetUserId ?? meCreate.Value + }; + } + + var roomOk = await _db.Rooms.AnyAsync(r => r.RoomId == create.RoomId && r.IsActive); + if (!roomOk) return BadRequest("Room not found or inactive."); + + var targetUserId = create.TargetUserId ?? create.RequestedByUserId; + var u = await _db.Users.AsNoTracking().FirstOrDefaultAsync(x => x.Id == targetUserId); + if (u == null) return BadRequest("Selected user not found."); + + int? departmentId = u.departmentId; + int? companyId = null; + if (departmentId is int depId) + { + companyId = await _db.Departments.AsNoTracking() + .Where(d => d.DepartmentId == depId) + .Select(d => d.CompanyId) + .FirstOrDefaultAsync(); + } + + var purpose = (create.Purpose ?? create.Description)?.Trim(); + var noteIn = (create.Note ?? create.Notes)?.Trim(); + + // normalize + snap to 30 + var startUtc = SnapToNearest30(CoerceToUtc(create.StartUtc)); + var endUtc = SnapToNearest30(CoerceToUtc(create.EndUtc)); + + // guards + if (endUtc <= startUtc) return BadRequest("End time must be after Start time."); + if (!IsSameLocalDate(startUtc, endUtc, AppTz)) + return BadRequest("End must be on the same calendar date as Start."); + + var localStart = TimeZoneInfo.ConvertTimeFromUtc(startUtc, AppTz); + var localEnd = TimeZoneInfo.ConvertTimeFromUtc(endUtc, AppTz); + if (localStart.TimeOfDay < new TimeSpan(8, 0, 0)) + return BadRequest("Start must be at or after 08:00."); + if (localEnd.TimeOfDay > new TimeSpan(20, 0, 0)) + return BadRequest("End must be at or before 20:00."); + + // NEW: disallow creating bookings in the past (rounded up to next :00 / :30) + var nowUtcCeil = SnapToNearest30(DateTime.UtcNow.AddSeconds(1)); // ceil to next 30-min tick + if (startUtc < nowUtcCeil) + return BadRequest("Start must be in the future."); + + + var entity = new Booking + { + RequestedByUserId = create.RequestedByUserId, + TargetUserId = create.TargetUserId ?? create.RequestedByUserId, + DepartmentId = departmentId, + CompanyId = companyId, + RoomId = create.RoomId, + Title = create.Title?.Trim() ?? "", + Purpose = string.IsNullOrWhiteSpace(purpose) ? null : purpose, + StartUtc = startUtc, + EndUtc = endUtc, + Note = string.IsNullOrWhiteSpace(noteIn) ? null : noteIn, + CreatedUtc = DateTime.UtcNow, + LastUpdatedUtc = DateTime.UtcNow, + CurrentStatus = BookingStatus.Pending + }; + + // data annotations + var vc = new ValidationContext(entity); + var vr = new List(); + if (!Validator.TryValidateObject(entity, vc, vr, true)) return BadRequest(vr); + + // overlap (Pending/Approved) + var overlapCreate = await _db.Bookings.AsNoTracking().AnyAsync(b => + b.RoomId == entity.RoomId && + (b.CurrentStatus == BookingStatus.Pending || b.CurrentStatus == BookingStatus.Approved) && + b.StartUtc < entity.EndUtc && entity.StartUtc < b.EndUtc); + + if (overlapCreate) return Conflict("This time slot overlaps an existing booking for the selected room."); + + _db.Bookings.Add(entity); + await _db.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetAsync), + new { id = entity.BookingId }, + new { id = entity.BookingId }); + } + + // ========================================================= + // ========================= PUT =========================== + // ========================================================= + // - Update booking: PUT /api/BookingsApi?id=123 + // - Update room: PUT /api/BookingsApi?scope=rooms&id=5 + [HttpPut] + public async Task PutAsync( + [FromQuery] int? id, + [FromQuery] string? scope, + [FromBody] System.Text.Json.JsonElement? body + ) + { + if (!id.HasValue || id <= 0) return BadRequest("Missing id."); + var json = body?.ToString() ?? "{}"; + + // ROOMS: UPDATE (Managers only) + if (string.Equals(scope, "rooms", StringComparison.OrdinalIgnoreCase)) + { + if (!IsManager()) return Unauthorized("Managers only."); + + var dto = JsonSerializer.Deserialize(json, JsonOpts); + + var r = await _db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id.Value); + if (r is null) return NotFound(); + + if (dto is not null) + { + if (dto.RoomName is not null) r.RoomName = dto.RoomName.Trim(); + if (dto.LocationCode is not null) r.LocationCode = string.IsNullOrWhiteSpace(dto.LocationCode) ? null : dto.LocationCode.Trim(); + if (dto.Capacity.HasValue) r.Capacity = dto.Capacity.Value; + if (dto.IsActive.HasValue) r.IsActive = dto.IsActive.Value; + } + await _db.SaveChangesAsync(); + return Ok(new { roomId = r.RoomId }); + } + + // BOOKING: UPDATE (Owner or Manager) + var payload = JsonSerializer.Deserialize(json, JsonOpts); + + var entity = await _db.Bookings.FirstOrDefaultAsync(x => x.BookingId == id.Value); + if (entity is null) return NotFound(); + + var me = GetCurrentUserId(); + if (!CanModify(entity, me)) + return Unauthorized("Only the creator or a manager can edit this booking."); + + if (entity.CurrentStatus is not BookingStatus.Pending and not BookingStatus.Rejected) + return Conflict("Only Pending or Rejected bookings can be updated."); + + // optional: switch rooms + if (payload?.RoomId is int newRoomId && newRoomId != entity.RoomId) + { + var roomOk2 = await _db.Rooms.AnyAsync(r => r.RoomId == newRoomId && r.IsActive); + if (!roomOk2) return BadRequest("New room not found or inactive."); + entity.RoomId = newRoomId; + } + + if (payload is not null) + { + if (payload.Title is not null) entity.Title = payload.Title.Trim(); + + var purpose = (payload.Purpose ?? payload.Description)?.Trim(); + if (payload.Purpose is not null || payload.Description is not null) + entity.Purpose = string.IsNullOrWhiteSpace(purpose) ? null : purpose; + + var noteIn = (payload.Note ?? payload.Notes)?.Trim(); + if (payload.Note is not null || payload.Notes is not null) + entity.Note = string.IsNullOrWhiteSpace(noteIn) ? null : noteIn; + + // normalize + snap to 30 using incoming or existing values + var newStartUtc = entity.StartUtc; + var newEndUtc = entity.EndUtc; + + if (payload.StartUtc.HasValue) + newStartUtc = SnapToNearest30(CoerceToUtc(payload.StartUtc.Value)); + if (payload.EndUtc.HasValue) + newEndUtc = SnapToNearest30(CoerceToUtc(payload.EndUtc.Value)); + + // NEW: also disallow moving an existing booking into the past + var nowUtcCeil = SnapToNearest30(DateTime.UtcNow.AddSeconds(1)); + if (newStartUtc < nowUtcCeil) + return BadRequest("Start must be in the future."); + + // guards + if (newEndUtc <= newStartUtc) return BadRequest("End time must be after Start time."); + if (!IsSameLocalDate(newStartUtc, newEndUtc, AppTz)) + return BadRequest("End date must be the same calendar date as Start."); + + var localStart = TimeZoneInfo.ConvertTimeFromUtc(newStartUtc, AppTz); + var localEnd = TimeZoneInfo.ConvertTimeFromUtc(newEndUtc, AppTz); + var windowStart = new TimeSpan(8, 0, 0); + var windowEnd = new TimeSpan(20, 0, 0); + if (localStart.TimeOfDay < windowStart || localEnd.TimeOfDay > windowEnd) + return BadRequest("Bookings must be between 08:00 and 20:00 local time."); + + entity.StartUtc = newStartUtc; + entity.EndUtc = newEndUtc; + } + + entity.LastUpdatedUtc = DateTime.UtcNow; + + // data annotations + var vc = new ValidationContext(entity); + var vr = new List(); + if (!Validator.TryValidateObject(entity, vc, vr, true)) return BadRequest(vr); + + // overlap on (possibly) new room/times + var overlap = await _db.Bookings.AsNoTracking().AnyAsync(b => + b.RoomId == entity.RoomId && + b.BookingId != entity.BookingId && + (b.CurrentStatus == BookingStatus.Pending || b.CurrentStatus == BookingStatus.Approved) && + b.StartUtc < entity.EndUtc && entity.StartUtc < b.EndUtc); + + if (overlap) return Conflict("This time slot overlaps an existing booking for the selected room."); + + await _db.SaveChangesAsync(); + return Ok(new { id = entity.BookingId }); + } + + // ========================================================= + // ======================= DELETE ========================== + // ========================================================= + // - Delete booking: DELETE /api/BookingsApi?id=123 + // - Delete room: DELETE /api/BookingsApi?scope=rooms&id=5 + [HttpDelete] + public async Task DeleteAsync([FromQuery] int? id, [FromQuery] string? scope) + { + if (!id.HasValue || id <= 0) return BadRequest("Missing id."); + + // ROOMS: HARD DELETE (Managers only) + if (string.Equals(scope, "rooms", StringComparison.OrdinalIgnoreCase)) + { + if (!IsManager()) return Unauthorized("Managers only."); + + var r = await _db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id.Value); + if (r is null) return NotFound(); + + try + { + _db.Rooms.Remove(r); + await _db.SaveChangesAsync(); + return Ok(new { deleted = true }); + } + catch (DbUpdateException) + { + // Likely foreign key in use (existing bookings) or DB constraint + return StatusCode(409, new { error = "Cannot delete: room is in use or active. Deactivate it instead." }); + } + catch (Exception) + { + return StatusCode(500, new { error = "Delete failed." }); + } + } + + // BOOKINGS: DELETE (only Pending/Rejected) + permission gate + var entity = await _db.Bookings.FirstOrDefaultAsync(x => x.BookingId == id.Value); + if (entity is null) return NotFound(); + + var me = GetCurrentUserId(); + if (!CanModify(entity, me)) + return Unauthorized("Only the creator or a manager can delete this booking."); + + // NEW: only Pending is editable + if (entity.CurrentStatus != BookingStatus.Pending) + return Conflict("Only Pending bookings can be edited."); + + + try + { + _db.Bookings.Remove(entity); + await _db.SaveChangesAsync(); + return Ok(new { deleted = true }); + } + catch (DbUpdateException) + { + return StatusCode(409, new { error = "Cannot delete: booking is referenced or locked." }); + } + catch (Exception) + { + return StatusCode(500, new { error = "Delete failed." }); + } + } + } +} diff --git a/Controllers/API/ITRequestAPI.cs b/Controllers/API/ITRequestAPI.cs new file mode 100644 index 0000000..43add09 --- /dev/null +++ b/Controllers/API/ITRequestAPI.cs @@ -0,0 +1,1812 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using PSTW_CentralSystem.Areas.IT.Models; +using PSTW_CentralSystem.DBContext; +using PSTW_CentralSystem.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using PSTW_CentralSystem.Areas.IT.Printing; +using QuestPDF.Fluent; +using Org.BouncyCastle.Ocsp; + +namespace PSTW_CentralSystem.Controllers.API +{ + [ApiController] + [Route("[controller]")] + [Authorize] // keep your policy if needed + public class ItRequestAPI : ControllerBase + { + + private static string Nz(string? s, string fallback = "") => + string.IsNullOrWhiteSpace(s) ? fallback : s; + + private readonly CentralSystemContext _db; + private readonly UserManager _userManager; + + public ItRequestAPI(CentralSystemContext db, UserManager userManager) + { + _db = db; + _userManager = userManager; + } + + // Helper for Edit window + private const int EDIT_WINDOW_HOURS_DEFAULT = 24; // <- change here anytime + private const int EDIT_WINDOW_HOURS_MIN = 1; + private const int EDIT_WINDOW_HOURS_MAX = 24 * 14; // clamp for safety (2 weeks) + + // === Feature flags === + // Toggle this to true if later wants Pending (no approvals yet) to be cancelable. + private const bool ALLOW_PENDING_CANCEL = false; + + private static int ResolveEditWindowHours(HttpRequest req, int? fromDto) + { + // Priority: DTO -> Header -> Query -> Default + if (fromDto.HasValue) return Math.Clamp(fromDto.Value, EDIT_WINDOW_HOURS_MIN, EDIT_WINDOW_HOURS_MAX); + + if (int.TryParse(req.Headers["X-Edit-Window-Hours"], out var h)) + return Math.Clamp(h, EDIT_WINDOW_HOURS_MIN, EDIT_WINDOW_HOURS_MAX); + + if (int.TryParse(req.Query["editWindowHours"], out var q)) + return Math.Clamp(q, EDIT_WINDOW_HOURS_MIN, EDIT_WINDOW_HOURS_MAX); + + return EDIT_WINDOW_HOURS_DEFAULT; + } + + + + // Helper to get current user id (int) + private int GetCurrentUserIdOrThrow() + { + var userIdStr = _userManager.GetUserId(User); + if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId)) + throw new UnauthorizedAccessException("Invalid user"); + return currentUserId; + } + // -------------------------------------------------------------------- + // GET: /ItRequestAPI/users?q=&take=100 + // Returns light user list for dropdowns + // -------------------------------------------------------------------- + [HttpGet("users")] + public async Task GetUsers([FromQuery] string? q = null, [FromQuery] int take = 200) + { + take = Math.Clamp(take, 1, 500); + + var users = _db.Users.AsQueryable(); + + if (!string.IsNullOrWhiteSpace(q)) + { + q = q.Trim(); + users = users.Where(u => + (u.UserName ?? "").Contains(q) || + (u.FullName ?? "").Contains(q) || + (u.Email ?? "").Contains(q)); + } + + var data = await users + .OrderBy(u => u.Id) + .Take(take) + .Select(u => new + { + id = u.Id, + name = u.FullName ?? u.UserName, + userName = u.UserName, + email = u.Email + }) + .ToListAsync(); + + return Ok(data); + } + // ===== Section B: Meta ===== + [HttpGet("sectionB/meta")] + public async Task SectionBMeta([FromQuery] int statusId) + { + int currentUserId; + try { currentUserId = GetCurrentUserIdOrThrow(); } + catch (Exception ex) { return Unauthorized(new { message = ex.Message }); } + + var status = await _db.ItRequestStatus + .Include(s => s.Request) + .Include(s => s.Flow) + .FirstOrDefaultAsync(s => s.StatusId == statusId); + + if (status == null) + return NotFound(new { message = "Status not found." }); + + if (status.Request == null) + return StatusCode(409, new { message = "This status row is orphaned (missing its request). Please re-create the request or fix the foreign key." }); + + var req = status.Request!; + var sectionB = await _db.ItRequestAssetInfo.FirstOrDefaultAsync(x => x.ItRequestId == status.ItRequestId); + + bool isRequestor = req.UserId == currentUserId; + bool isItMember = await _db.ItTeamMembers.AnyAsync(t => t.UserId == currentUserId); + + bool sectionBSent = sectionB?.SectionBSent == true; + bool locked = sectionBSent || (sectionB?.RequestorAccepted == true && sectionB?.ItAccepted == true); + + return Ok(new + { + overallStatus = status.OverallStatus ?? "Pending", + requestorName = req.StaffName, + isRequestor, + isItMember, + sectionB = sectionB == null ? null : new + { + saved = true, + assetNo = sectionB.AssetNo ?? "", + machineId = sectionB.MachineId ?? "", + ipAddress = sectionB.IpAddress ?? "", + wiredMac = sectionB.WiredMac ?? "", + wifiMac = sectionB.WifiMac ?? "", + dialupAcc = sectionB.DialupAcc ?? "", + remarks = sectionB.Remarks ?? "", + lastEditedBy = sectionB.LastEditedByName ?? "", + lastEditedAt = sectionB.LastEditedAt + }, + sectionBSent, + sectionBSentAt = sectionB?.SectionBSentAt, + locked, + requestorAccepted = sectionB?.RequestorAccepted ?? false, + requestorAcceptedAt = sectionB?.RequestorAcceptedAt, + itAccepted = sectionB?.ItAccepted ?? false, + itAcceptedAt = sectionB?.ItAcceptedAt, + itAcceptedBy = sectionB?.ItAcceptedByName + }); + } + + + + [HttpGet("sectionB/pdf")] + public async Task SectionBPdf([FromQuery] int statusId) + { + var status = await _db.ItRequestStatus + .Include(s => s.Request).ThenInclude(r => r!.Hardware) + .Include(s => s.Request).ThenInclude(r => r!.Emails) + .Include(s => s.Request).ThenInclude(r => r!.OsRequirements) + .Include(s => s.Request).ThenInclude(r => r!.Software) + .Include(s => s.Request).ThenInclude(r => r!.SharedPermissions) + .Include(s => s.Flow) + .FirstOrDefaultAsync(s => s.StatusId == statusId); + + if (status == null) return NotFound(); + if (status.Request == null) + return StatusCode(409, new { message = "Missing Request for this status." }); + + var b = await _db.ItRequestAssetInfo + .SingleOrDefaultAsync(x => x.ItRequestId == status.ItRequestId); + + string? hardwareJustification = null; + if (status.Request?.Hardware != null) + { + var firstDistinct = status.Request.Hardware + .Select(h => h.Justification?.Trim()) + .Where(j => !string.IsNullOrWhiteSpace(j)) + .Distinct(StringComparer.InvariantCultureIgnoreCase) + .FirstOrDefault(); + + hardwareJustification = firstDistinct; + } + + + if (status is null) return NotFound(); + if (status.Request is null) + return StatusCode(409, new { message = "Missing Request for this status." }); + + var req = status.Request; // non-null after the guard + + + // Build the report model + var m = new ItRequestReportModel + { + ItRequestId = status.ItRequestId, + StatusId = status.StatusId, + RevNo = status.StatusId.ToString(), + OverallStatus = status.OverallStatus ?? "Pending", + SubmitDate = req.SubmitDate, // if nullable: req.SubmitDate ?? DateTime.MinValue + + StaffName = req.StaffName ?? "", + CompanyName = req.CompanyName ?? "", + DepartmentName = req.DepartmentName ?? "", + Designation = req.Designation ?? "", + Location = req.Location ?? "", + EmploymentStatus = req.EmploymentStatus ?? "", + ContractEndDate = req.ContractEndDate, + RequiredDate = req.RequiredDate, + PhoneExt = req.PhoneExt ?? "", + + // …keep the rest, but switch to 'req' for child lists too: + Hardware = req.Hardware?.Select(h => + string.IsNullOrWhiteSpace(h.OtherDescription) + ? (h.Category ?? "") + : $"{h.Category} – {h.OtherDescription}") + .Where(s => !string.IsNullOrWhiteSpace(s)).ToList() ?? new(), + + Justification = hardwareJustification, + + Emails = req.Emails? + .Select(e => e.ProposedAddress ?? "") + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToList() ?? new(), + + OsRequirements = req.OsRequirements? + .Select(o => o.RequirementText ?? "") + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToList() ?? new(), + + Software = req.Software? + .Select(sw => (sw.Bucket ?? "", sw.Name ?? "", sw.OtherName, sw.Notes)) + .ToList() ?? new(), + + SharedPerms = req.SharedPermissions? + .Select(sp => (sp.ShareName ?? "", sp.CanRead, sp.CanWrite, sp.CanDelete, sp.CanRemove)) + .ToList() ?? new(), + + AssetNo = b?.AssetNo ?? "", + MachineId = b?.MachineId ?? "", + IpAddress = b?.IpAddress ?? "", + WiredMac = b?.WiredMac ?? "", + WifiMac = b?.WifiMac ?? "", + DialupAcc = b?.DialupAcc ?? "", + Remarks = b?.Remarks ?? "", + RequestorName = req.StaffName ?? "", + RequestorAcceptedAt = b?.RequestorAcceptedAt, + ItCompletedBy = b?.ItAcceptedByName ?? "", + ItAcceptedAt = b?.ItAcceptedAt + }; + + + if (status.Request?.Hardware != null) + { + foreach (var h in status.Request.Hardware) + { + var cat = (h.Category ?? "").Trim(); + var purpose = (h.Purpose ?? "").Trim().ToLowerInvariant(); + + // Purposes (left column) + if (purpose == "newrecruitment" || purpose == "new recruitment" || purpose == "new") + m.HwPurposeNewRecruitment = true; + else if (purpose == "replacement") + m.HwPurposeReplacement = true; + else if (purpose == "additional") + m.HwPurposeAdditional = true; + + // Categories (right column) + switch (cat) + { + case "DesktopAllIn": m.HwDesktopAllIn = true; break; + case "NotebookAllIn": m.HwNotebookAllIn = true; break; + case "DesktopOnly": m.HwDesktopOnly = true; break; + case "NotebookOnly": m.HwNotebookOnly = true; break; + case "NotebookBattery": m.HwNotebookBattery = true; break; + case "PowerAdapter": m.HwPowerAdapter = true; break; + case "Mouse": m.HwMouse = true; break; + case "ExternalHDD": m.HwExternalHdd = true; break; + case "Other": + if (!string.IsNullOrWhiteSpace(h.OtherDescription)) + m.HwOtherText = h.OtherDescription!.Trim(); + break; + } + } + } + + // ---- Approver names + stage dates ------------------------------------ + // Build a name map for all approver IDs (null-safe) + var approverIds = new int?[] + { + status.Flow?.HodUserId, + status.Flow?.GroupItHodUserId, + status.Flow?.FinHodUserId, + status.Flow?.MgmtUserId + }; + + var idSet = approverIds + .Where(i => i.HasValue).Select(i => i!.Value) + .Distinct().ToList(); + + var nameMap = await _db.Users + .Where(u => idSet.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.FullName ?? u.UserName ?? $"User {u.Id}"); + + string Name(int? id) => id.HasValue ? nameMap.GetValueOrDefault(id.Value, "") : ""; + + // Push names into the report model for PDF + m.HodApprovedBy = Name(status.Flow?.HodUserId); + m.GitHodApprovedBy = Name(status.Flow?.GroupItHodUserId); // DB field: GroupItHodUserId + m.FinHodApprovedBy = Name(status.Flow?.FinHodUserId); + m.MgmtApprovedBy = Name(status.Flow?.MgmtUserId); + + // Also expose the per-stage submit dates (your PDF uses them) + m.HodSubmitDate = status.HodSubmitDate; + m.GitHodSubmitDate = status.GitHodSubmitDate; + m.FinHodSubmitDate = status.FinHodSubmitDate; + m.MgmtSubmitDate = status.MgmtSubmitDate; + + // ---- Gate: only after both acceptances ------------------------------- + if (!(b?.RequestorAccepted == true && b?.ItAccepted == true)) + return BadRequest(new { message = "PDF available after both acceptances." }); + + var pdf = new ItRequestPdfService().Generate(m); + //var fileName = $"IT_Request_{m.ItRequestId}_SectionB.pdf"; + return File(pdf, "application/pdf"); + } + + + + + // ===== Section B: Save (IT team only, overall must be Approved) ===== + public class SectionBSaveDto + { + public int StatusId { get; set; } + public string AssetNo { get; set; } = string.Empty; + public string MachineId { get; set; } = string.Empty; + public string IpAddress { get; set; } = string.Empty; + public string WiredMac { get; set; } = string.Empty; + public string WifiMac { get; set; } = string.Empty; + public string DialupAcc { get; set; } = string.Empty; + public string Remarks { get; set; } = string.Empty; + } + + // REPLACE the old method with this one (same route name kept) + [HttpGet("sectionB/approvedList")] + public async Task SectionBList([FromQuery] int month, [FromQuery] int year) + { + int currentUserId; + try { currentUserId = GetCurrentUserIdOrThrow(); } + catch (Exception ex) { return Unauthorized(new { message = ex.Message }); } + + bool isItMember = await _db.ItTeamMembers.AnyAsync(t => t.UserId == currentUserId); + if (!isItMember) return Ok(new { isItMember = false, data = Array.Empty() }); + + // Pull ALL requests for the month by SubmitDate (so "not eligible yet" also appear) + var baseQuery = _db.ItRequestStatus + .Include(s => s.Request) + .Where(s => s.Request != null && + s.Request.SubmitDate.Month == month && + s.Request.SubmitDate.Year == year) + .Select(s => new + { + S = s, + ApprovedAt = s.MgmtSubmitDate ?? s.FinHodSubmitDate ?? s.GitHodSubmitDate ?? s.HodSubmitDate + }); + + var rows = await baseQuery + .Select(x => new + { + x.S.StatusId, + x.S.ItRequestId, + StaffName = x.S.Request!.StaffName, + DepartmentName = x.S.Request.DepartmentName, + SubmitDate = x.S.Request.SubmitDate, + OverallStatus = x.S.OverallStatus ?? "Draft", + ApprovedAt = x.ApprovedAt, + + B = _db.ItRequestAssetInfo + .Where(b => b.ItRequestId == x.S.ItRequestId) + .Select(b => new + { + saved = true, + b.SectionBSent, + b.RequestorAccepted, + b.ItAccepted, + b.RequestorAcceptedAt, + b.ItAcceptedAt, + b.ItAcceptedByName, + b.LastEditedAt, + b.LastEditedByName + }) + .FirstOrDefault() + }) + .ToListAsync(); + + // Stage derivation + string Stage(string overall, dynamic b) + { + bool isApproved = string.Equals(overall, "Approved", StringComparison.OrdinalIgnoreCase); + + if (!isApproved) return "NOT_ELIGIBLE"; // first part not approved yet + if (b == null) return "PENDING"; // no Section B row yet + + bool sent = b.SectionBSent == true; + bool req = b.RequestorAccepted == true; + bool it = b.ItAccepted == true; + + if (req && it) return "COMPLETE"; // both accepted + if (!sent) return "DRAFT"; // saved but not sent + return "AWAITING"; // one side accepted + } + + int Rank(string stage) => stage switch + { + "PENDING" => 0, + "DRAFT" => 1, + "AWAITING" => 2, + "COMPLETE" => 3, + "NOT_ELIGIBLE" => 4, + _ => 9 + }; + + var data = rows + .Select(x => + { + var stage = Stage(x.OverallStatus, x.B); + return new + { + statusId = x.StatusId, + itRequestId = x.ItRequestId, + staffName = x.StaffName, + departmentName = x.DepartmentName, + submitDate = x.SubmitDate, + approvedAt = x.ApprovedAt, + overallStatus = x.OverallStatus, + stage, // <-- used by UI badges/sorting + stageRank = Rank(stage), // <-- used by UI sorting + + sb = new + { + saved = x.B != null, + requestorAccepted = x.B?.RequestorAccepted ?? false, + requestorAcceptedAt = x.B?.RequestorAcceptedAt, + itAccepted = x.B?.ItAccepted ?? false, + itAcceptedAt = x.B?.ItAcceptedAt, + itAcceptedBy = x.B?.ItAcceptedByName, + lastEditedAt = x.B?.LastEditedAt, + lastEditedBy = x.B?.LastEditedByName + } + }; + }) + .Where(x => x.stage != "NOT_ELIGIBLE") + .OrderBy(x => x.stageRank) + .ThenByDescending(x => x.approvedAt ?? x.submitDate) + .ToList(); + + return Ok(new { isItMember = true, data }); + } + + + + + [HttpPost("sectionB/save")] + public async Task SectionBSave([FromBody] SectionBSaveDto dto) + { + int currentUserId; + try { currentUserId = GetCurrentUserIdOrThrow(); } catch (Exception ex) { return Unauthorized(ex.Message); } + + var status = await _db.ItRequestStatus + .Include(s => s.Request) + .FirstOrDefaultAsync(s => s.StatusId == dto.StatusId); + + if (status == null) return NotFound("Status not found"); + if ((status.OverallStatus ?? "Pending") != "Approved") + return BadRequest(new { message = "Section B is available only after OverallStatus is Approved." }); + + bool isItMember = await _db.ItTeamMembers.AnyAsync(t => t.UserId == currentUserId); + if (!isItMember) return Unauthorized("Only IT Team members can edit Section B."); + + // basic server-side validation (at least one identifier) + List errors = new(); + if (string.IsNullOrWhiteSpace(dto.AssetNo) && + string.IsNullOrWhiteSpace(dto.MachineId) && + string.IsNullOrWhiteSpace(dto.IpAddress)) + errors.Add("Provide at least one of: Asset No, Machine ID, or IP Address."); + if (errors.Count > 0) return BadRequest(new { message = string.Join(" ", errors) }); + + var existing = await _db.ItRequestAssetInfo.FirstOrDefaultAsync(x => x.ItRequestId == status.ItRequestId); + if (existing != null && existing.SectionBSent) + return BadRequest(new { message = "This Section B was sent. It can’t be edited anymore." }); + + var editor = await _db.Users.FirstOrDefaultAsync(u => u.Id == currentUserId); + var editorName = editor?.FullName ?? editor?.UserName ?? $"User {currentUserId}"; + var now = DateTime.Now; + + try + { + if (existing == null) + { + existing = new ItRequestAssetInfo + { + ItRequestId = status.ItRequestId, + AssetNo = dto.AssetNo?.Trim(), + MachineId = dto.MachineId?.Trim(), + IpAddress = dto.IpAddress?.Trim(), + WiredMac = dto.WiredMac?.Trim(), + WifiMac = dto.WifiMac?.Trim(), + DialupAcc = dto.DialupAcc?.Trim(), + Remarks = dto.Remarks?.Trim(), + LastEditedAt = now, + LastEditedByUserId = currentUserId, + LastEditedByName = editorName, + SectionBSent = false, + SectionBSentAt = null, + // ensure accept flags are clear on draft save + RequestorAccepted = false, + RequestorAcceptedAt = null, + ItAccepted = false, + ItAcceptedAt = null, + ItAcceptedByUserId = null, + ItAcceptedByName = null + }; + _db.ItRequestAssetInfo.Add(existing); + } + else + { + existing.AssetNo = dto.AssetNo?.Trim(); + existing.MachineId = dto.MachineId?.Trim(); + existing.IpAddress = dto.IpAddress?.Trim(); + existing.WiredMac = dto.WiredMac?.Trim(); + existing.WifiMac = dto.WifiMac?.Trim(); + existing.DialupAcc = dto.DialupAcc?.Trim(); + existing.Remarks = dto.Remarks?.Trim(); + existing.LastEditedAt = now; + existing.LastEditedByUserId = currentUserId; + existing.LastEditedByName = editorName; + + // Saving draft always keeps it editable (not sent) + existing.SectionBSent = false; + existing.SectionBSentAt = null; + + // if someone had previously accepted, a new draft save clears them + existing.RequestorAccepted = false; + existing.RequestorAcceptedAt = null; + existing.ItAccepted = false; + existing.ItAcceptedAt = null; + existing.ItAcceptedByUserId = null; + existing.ItAcceptedByName = null; + } + + await _db.SaveChangesAsync(); + return Ok(new { message = "Draft saved." }); + } + catch (DbUpdateException dbx) + { + return StatusCode(400, new { message = "Validation/DB error while saving Section B.", detail = dbx.InnerException?.Message ?? dbx.Message }); + } + catch (Exception ex) + { + return StatusCode(500, new { message = "Server error while saving Section B.", detail = ex.Message }); + } + } + + public class SectionBSendDto + { + public int StatusId { get; set; } + public string? AssetNo { get; set; } + public string? MachineId { get; set; } + public string? IpAddress { get; set; } + public string? WiredMac { get; set; } + public string? WifiMac { get; set; } + public string? DialupAcc { get; set; } + public string? Remarks { get; set; } + } + + + [HttpPost("sectionB/send")] + public async Task SectionBSend([FromBody] SectionBSendDto dto) + { + int currentUserId; + try { currentUserId = GetCurrentUserIdOrThrow(); } catch (Exception ex) { return Unauthorized(ex.Message); } + + var status = await _db.ItRequestStatus + .Include(s => s.Request) + .FirstOrDefaultAsync(s => s.StatusId == dto.StatusId); + + if (status == null) return NotFound(new { message = "Status not found" }); + if ((status.OverallStatus ?? "Pending") != "Approved") + return BadRequest(new { message = "Section B can be sent only after OverallStatus is Approved." }); + + bool isItMember = await _db.ItTeamMembers.AnyAsync(t => t.UserId == currentUserId); + if (!isItMember) return Unauthorized(new { message = "Only IT can send Section B." }); + + var b = await _db.ItRequestAssetInfo.FirstOrDefaultAsync(x => x.ItRequestId == status.ItRequestId); + if (b?.SectionBSent == true) + return BadRequest(new { message = "Section B already sent." }); + + var editor = await _db.Users.FirstOrDefaultAsync(u => u.Id == currentUserId); + var editorName = editor?.FullName ?? editor?.UserName ?? $"User {currentUserId}"; + var now = DateTime.Now; + + if (b == null) + { + b = new ItRequestAssetInfo + { + ItRequestId = status.ItRequestId, + RequestorAccepted = false, + RequestorAcceptedAt = null, + ItAccepted = false, + ItAcceptedAt = null, + ItAcceptedByUserId = null, + ItAcceptedByName = null + }; + _db.ItRequestAssetInfo.Add(b); + } + + // Apply incoming fields (allow send without prior draft) + if (dto.AssetNo != null) b.AssetNo = dto.AssetNo.Trim(); + if (dto.MachineId != null) b.MachineId = dto.MachineId.Trim(); + if (dto.IpAddress != null) b.IpAddress = dto.IpAddress.Trim(); + if (dto.WiredMac != null) b.WiredMac = dto.WiredMac.Trim(); + if (dto.WifiMac != null) b.WifiMac = dto.WifiMac.Trim(); + if (dto.DialupAcc != null) b.DialupAcc = dto.DialupAcc.Trim(); + if (dto.Remarks != null) b.Remarks = dto.Remarks.Trim(); + + b.LastEditedAt = now; + b.LastEditedByUserId = currentUserId; + b.LastEditedByName = editorName; + + // Require at least one identifier + if (string.IsNullOrWhiteSpace(b.AssetNo) && + string.IsNullOrWhiteSpace(b.MachineId) && + string.IsNullOrWhiteSpace(b.IpAddress)) + return BadRequest(new { message = "Provide at least Asset No / Machine ID / IP Address before sending." }); + + b.SectionBSent = true; + b.SectionBSentAt = now; + + await _db.SaveChangesAsync(); + return Ok(new { message = "Section B sent. It is now locked for editing until both acceptances complete." }); + } + + + public class SectionBResetDto { public int StatusId { get; set; } } + + [HttpPost("sectionB/reset")] + public async Task SectionBReset([FromBody] SectionBResetDto dto) + { + int currentUserId; + try { currentUserId = GetCurrentUserIdOrThrow(); } catch (Exception ex) { return Unauthorized(ex.Message); } + + bool isItMember = await _db.ItTeamMembers.AnyAsync(t => t.UserId == currentUserId); + if (!isItMember) return Unauthorized(new { message = "Only IT can reset Section B." }); + + var status = await _db.ItRequestStatus + .Include(s => s.Request) + .FirstOrDefaultAsync(s => s.StatusId == dto.StatusId); + if (status == null) return NotFound(new { message = "Status not found" }); + + var b = await _db.ItRequestAssetInfo.FirstOrDefaultAsync(x => x.ItRequestId == status.ItRequestId); + if (b == null) return Ok(new { message = "Nothing to reset." }); + + if (b.SectionBSent) + return BadRequest(new { message = "Cannot reset after Section B was sent." }); + + b.AssetNo = b.MachineId = b.IpAddress = b.WiredMac = b.WifiMac = b.DialupAcc = b.Remarks = null; + b.RequestorAccepted = false; b.RequestorAcceptedAt = null; + b.ItAccepted = false; b.ItAcceptedAt = null; b.ItAcceptedByUserId = null; b.ItAcceptedByName = null; + b.LastEditedAt = DateTime.Now; + b.LastEditedByUserId = currentUserId; + b.LastEditedByName = (await _db.Users.FirstOrDefaultAsync(u => u.Id == currentUserId))?.FullName; + + await _db.SaveChangesAsync(); + return Ok(new { message = "Section B reset to empty draft." }); + } + + + + // ===== Section B: Accept (REQUESTOR or IT) ===== + public class SectionBAcceptDto { public int StatusId { get; set; } public string By { get; set; } = string.Empty; } // REQUESTOR | IT + + [HttpPost("sectionB/accept")] + public async Task SectionBAccept([FromBody] SectionBAcceptDto dto) + { + int currentUserId; + try { currentUserId = GetCurrentUserIdOrThrow(); } catch (Exception ex) { return Unauthorized(ex.Message); } + + var status = await _db.ItRequestStatus + .Include(s => s.Request) + .FirstOrDefaultAsync(s => s.StatusId == dto.StatusId); + if (status == null) return NotFound("Status not found"); + + var sectionB = await _db.ItRequestAssetInfo.FirstOrDefaultAsync(x => x.ItRequestId == status.ItRequestId); + if (sectionB == null) return BadRequest(new { message = "Save Section B before acceptance." }); + + if (!sectionB.SectionBSent) + return BadRequest(new { message = "Acceptances are only available after Section B is sent." }); + + var now = DateTime.Now; + var actor = await _db.Users.FirstOrDefaultAsync(u => u.Id == currentUserId); + var actorName = actor?.FullName ?? actor?.UserName ?? $"User {currentUserId}"; + + if (string.Equals(dto.By, "REQUESTOR", StringComparison.OrdinalIgnoreCase)) + { + if (status.Request.UserId != currentUserId) + return Unauthorized("Only the requestor can perform this acceptance."); + if (sectionB.RequestorAccepted) + return BadRequest(new { message = "Requestor already accepted." }); + + sectionB.RequestorAccepted = true; + sectionB.RequestorAcceptedAt = now; + } + else if (string.Equals(dto.By, "IT", StringComparison.OrdinalIgnoreCase)) + { + bool isItMember = await _db.ItTeamMembers.AnyAsync(t => t.UserId == currentUserId); + if (!isItMember) return Unauthorized("Only IT Team members can accept as IT."); + if (sectionB.ItAccepted) + return BadRequest(new { message = "IT already accepted." }); + + sectionB.ItAccepted = true; + sectionB.ItAcceptedAt = now; + sectionB.ItAcceptedByUserId = currentUserId; + sectionB.ItAcceptedByName = actorName; + } + else + { + return BadRequest(new { message = "Unknown acceptance type." }); + } + + await _db.SaveChangesAsync(); + return Ok(new { message = "Accepted." }); + } + + + // ===== IT Team maintenance (simple stub) ===== + // GET: /ItRequestAPI/itTeam + [HttpGet("itTeam")] + public async Task GetItTeam() => + Ok(await _db.ItTeamMembers.Select(t => t.UserId).ToListAsync()); + + // POST: /ItRequestAPI/itTeam body: { userIds: [1,2,3] } + public class ItTeamDto { public List UserIds { get; set; } = new(); } + [HttpPost("itTeam")] + public async Task SetItTeam([FromBody] ItTeamDto dto) + { + // Add your own admin authorization guard here + var all = _db.ItTeamMembers; + _db.ItTeamMembers.RemoveRange(all); + await _db.SaveChangesAsync(); + + foreach (var uid in dto.UserIds.Distinct()) + _db.ItTeamMembers.Add(new ItTeamMember { UserId = uid }); + + await _db.SaveChangesAsync(); + return Ok(new { message = "IT Team updated." }); + } + + + // -------------------------------------------------------------------- + // GET: /ItRequestAPI/me + // Pull snapshot from aspnetusers -> departments -> companies + // Used by Create.cshtml to prefill read-only requester details + // -------------------------------------------------------------------- + [HttpGet("me")] + public async Task Me() + { + var userIdStr = _userManager.GetUserId(User); + if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId)) + return Unauthorized("Invalid user"); + + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == currentUserId); + if (user == null) return NotFound("User not found"); + + var dept = await _db.Departments.FirstOrDefaultAsync(d => d.DepartmentId == user.departmentId); + var comp = dept != null + ? await _db.Companies.FirstOrDefaultAsync(c => c.CompanyId == dept.CompanyId) + : null; + + return Ok(new + { + userId = user.Id, + staffName = user.FullName ?? user.UserName, + departmentId = dept?.DepartmentId, + departmentName = dept?.DepartmentName ?? "", + companyId = comp?.CompanyId, + companyName = comp?.CompanyName ?? "", + designation = "", // adjust if you actually store this somewhere + location = "", + employmentStatus = "", + contractEndDate = (DateTime?)null, + phoneExt = user.PhoneNumber ?? "" + }); + } + #region Approval razor + // -------------------------------------------------------------------- + // GET: /ItRequestAPI/pending?month=9&year=2025 + // -------------------------------------------------------------------- + [HttpGet("pending")] + public async Task GetPending(int month, int year) + { + var userIdStr = _userManager.GetUserId(User); + if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId)) + return Unauthorized("Invalid or missing user ID"); + + // Which flows is this user part of, and what role in each? + var flows = await _db.ItApprovalFlows + .Where(f => f.HodUserId == currentUserId || + f.GroupItHodUserId == currentUserId || + f.FinHodUserId == currentUserId || + f.MgmtUserId == currentUserId) + .ToListAsync(); + + if (!flows.Any()) + return Ok(new { roles = Array.Empty(), data = Array.Empty() }); + + var flowRoleMap = flows.ToDictionary( + f => f.ItApprovalFlowId, + f => f.HodUserId == currentUserId ? "HOD" : + f.GroupItHodUserId == currentUserId ? "GIT_HOD" : + f.FinHodUserId == currentUserId ? "FIN_HOD" : "MGMT" + ); + + var statuses = await _db.ItRequestStatus + .Include(s => s.Request) + .Where(s => s.Request != null && + s.Request.SubmitDate.Month == month + && s.Request.SubmitDate.Year == year + && (s.OverallStatus ?? "Draft") != "Draft" // hide drafts + && s.OverallStatus != "Cancelled") + .ToListAsync(); + + var results = statuses + .Where(s => flowRoleMap.ContainsKey(s.ItApprovalFlowId)) + .Select(s => + { + var role = flowRoleMap[s.ItApprovalFlowId]; + + // quick flags + bool hodApproved = s.HodStatus == "Approved"; + bool gitApproved = s.GitHodStatus == "Approved"; + bool finApproved = s.FinHodStatus == "Approved"; + bool anyRejected = (s.HodStatus == "Rejected" || + s.GitHodStatus == "Rejected" || + s.FinHodStatus == "Rejected" || + s.MgmtStatus == "Rejected"); + + string currentUserStatus = "N/A"; + bool canApprove = false; + + if (role == "HOD") + { + currentUserStatus = s.HodStatus ?? "Pending"; + canApprove = (currentUserStatus == "Pending"); // first approver always sees its own pending + } + else if (role == "GIT_HOD") + { + // only after HOD approved and not previously rejected + if (!anyRejected && hodApproved) + { + currentUserStatus = s.GitHodStatus ?? "Pending"; + canApprove = (currentUserStatus == "Pending"); + } + else + { + // don’t surface “pending” for future approver + currentUserStatus = (s.GitHodStatus ?? "N/A"); + } + } + else if (role == "FIN_HOD") + { + if (!anyRejected && hodApproved && gitApproved) + { + currentUserStatus = s.FinHodStatus ?? "Pending"; + canApprove = (currentUserStatus == "Pending"); + } + else + { + currentUserStatus = (s.FinHodStatus ?? "N/A"); + } + } + else // MGMT + { + if (!anyRejected && hodApproved && gitApproved && finApproved) + { + currentUserStatus = s.MgmtStatus ?? "Pending"; + canApprove = (currentUserStatus == "Pending"); + } + else + { + currentUserStatus = (s.MgmtStatus ?? "N/A"); + } + } + + // include row ONLY IF: + // - this approver can act now (pending for them), OR + // - this approver already decided (for Completed tab) + bool includeForUser = + canApprove || + currentUserStatus == "Approved" || + currentUserStatus == "Rejected"; + + return new + { + s.StatusId, + s.ItRequestId, + s.Request.StaffName, + s.Request.DepartmentName, + SubmitDate = s.Request.SubmitDate, + s.HodStatus, + s.GitHodStatus, + s.FinHodStatus, + s.MgmtStatus, + OverallStatus = s.OverallStatus, + Role = role, + CurrentUserStatus = currentUserStatus, + CanApprove = canApprove, + IsOverallRejected = anyRejected + }; + }) + .Where(r => r != null && (r.CanApprove || r.CurrentUserStatus == "Approved" || r.CurrentUserStatus == "Rejected")) + .ToList(); + + return Ok(new + { + roles = flowRoleMap.Values.Distinct().ToList(), + data = results + }); + } + + + + // -------------------------------------------------------------------- + // POST: /ItRequestAPI/approveReject + // -------------------------------------------------------------------- + public class ApproveRejectDto + { + public int StatusId { get; set; } + public string Decision { get; set; } = string.Empty; // "Approved" or "Rejected" + public string? Comment { get; set; } + } + + [HttpPost("approveReject")] + public async Task ApproveReject([FromBody] ApproveRejectDto dto) + { + if (dto == null || string.IsNullOrWhiteSpace(dto.Decision)) + return BadRequest(new { message = "Invalid request" }); + + var userIdStr = _userManager.GetUserId(User); + if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId)) + return Unauthorized("Invalid user"); + + var status = await _db.ItRequestStatus + .Include(s => s.Flow) + .Include(s => s.Request) + .FirstOrDefaultAsync(s => s.StatusId == dto.StatusId); + + if (status == null) return NotFound("Status not found"); + + var overall = status.OverallStatus ?? "Draft"; + + // Drafts cannot be approved/rejected ever + if (overall == "Draft") + return StatusCode(409, new { message = "This request isn’t submitted yet. Approvals are only allowed once it is Pending." }); + + // CANCELLED: hard stop + if (string.Equals(overall, "Cancelled", StringComparison.OrdinalIgnoreCase)) + return StatusCode(409, new { message = "This request has been Cancelled and cannot be approved or rejected." }); + + // If any earlier stage already rejected, block + if (status.HodStatus == "Rejected" || status.GitHodStatus == "Rejected" || + status.FinHodStatus == "Rejected" || status.MgmtStatus == "Rejected") + return BadRequest(new { message = "Request already rejected by a previous approver." }); + + var now = DateTime.Now; + string decision = dto.Decision.Trim(); + + if (status.Flow.HodUserId == currentUserId && status.HodStatus == "Pending") + { + status.HodStatus = decision; status.HodSubmitDate = now; + } + else if (status.Flow.GroupItHodUserId == currentUserId && status.GitHodStatus == "Pending" && status.HodStatus == "Approved") + { + status.GitHodStatus = decision; status.GitHodSubmitDate = now; + } + else if (status.Flow.FinHodUserId == currentUserId && status.FinHodStatus == "Pending" && + status.HodStatus == "Approved" && status.GitHodStatus == "Approved") + { + status.FinHodStatus = decision; status.FinHodSubmitDate = now; + } + else if (status.Flow.MgmtUserId == currentUserId && status.MgmtStatus == "Pending" && + status.HodStatus == "Approved" && status.GitHodStatus == "Approved" && status.FinHodStatus == "Approved") + { + status.MgmtStatus = decision; status.MgmtSubmitDate = now; + } + else + { + return BadRequest(new { message = "Not authorized to act at this stage." }); + } + + if (decision == "Rejected") + status.OverallStatus = "Rejected"; + else if (status.HodStatus == "Approved" && status.GitHodStatus == "Approved" && + status.FinHodStatus == "Approved" && status.MgmtStatus == "Approved") + status.OverallStatus = "Approved"; + + await _db.SaveChangesAsync(); + return Ok(new { message = "Decision recorded successfully." }); + } + + #endregion + + // -------------------------------------------------------------------- + // GET: /ItRequestAPI/myRequests + // -------------------------------------------------------------------- + [HttpGet("myRequests")] + public async Task MyRequests([FromQuery] string? status = null, + [FromQuery] DateTime? from = null, + [FromQuery] DateTime? to = null, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50) + { + var userIdStr = _userManager.GetUserId(User); + if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int userId)) + return Unauthorized("Invalid user"); + + var q = _db.ItRequests + .Join(_db.ItRequestStatus, r => r.ItRequestId, s => s.ItRequestId, (r, s) => new { r, s }) + .Where(x => x.r.UserId == userId) + .AsQueryable(); + + if (!string.IsNullOrWhiteSpace(status)) + q = q.Where(x => x.s.OverallStatus == status); + + if (from.HasValue) q = q.Where(x => x.r.SubmitDate >= from.Value); + if (to.HasValue) q = q.Where(x => x.r.SubmitDate < to.Value.AddDays(1)); + + var total = await q.CountAsync(); + var data = await q + .OrderByDescending(x => x.r.SubmitDate) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .Select(x => new + { + x.r.ItRequestId, + x.s.StatusId, + x.r.StaffName, + x.r.DepartmentName, + x.r.CompanyName, + x.r.RequiredDate, + x.r.SubmitDate, + OverallStatus = x.s.OverallStatus, + + // extra fields used by UI action buttons + IsLockedForEdit = x.r.IsLockedForEdit, + EditableUntil = x.r.EditableUntil, + HodStatus = x.s.HodStatus, + GitHodStatus = x.s.GitHodStatus, + FinHodStatus = x.s.FinHodStatus, + MgmtStatus = x.s.MgmtStatus + }) + .ToListAsync(); + + // post-shape with flags (kept in-memory to keep SQL simple) + var shaped = data.Select(d => + { + var overall = d.OverallStatus ?? "Draft"; + var remaining = d.EditableUntil.HasValue + ? Math.Max(0, (int)(d.EditableUntil.Value - DateTime.UtcNow).TotalSeconds) + : 0; + + bool isDraft = overall == "Draft"; + bool canEditDraft = isDraft && !d.IsLockedForEdit && remaining > 0; + + bool allPending = + (d.HodStatus ?? "Pending") == "Pending" && + (d.GitHodStatus ?? "Pending") == "Pending" && + (d.FinHodStatus ?? "Pending") == "Pending" && + (d.MgmtStatus ?? "Pending") == "Pending"; + + bool canCancel = isDraft || (ALLOW_PENDING_CANCEL && overall == "Pending" && allPending); + + + // optional: hint URL for your "View" button in Draft tab + var editUrlHint = $"/IT/ApprovalDashboard/Edit?statusId={d.StatusId}"; + + return new + { + d.ItRequestId, + d.StatusId, + d.StaffName, + d.DepartmentName, + d.CompanyName, + d.RequiredDate, + d.SubmitDate, + OverallStatus = overall, + canEditDraft, + canCancel, + editUrlHint, + remainingSeconds = remaining + }; + }).ToList(); + + return Ok(new { total, page, pageSize, data = shaped }); + + } + + // -------------------------------------------------------------------- + // Flows CRUD + // -------------------------------------------------------------------- + [HttpGet("flows")] + public async Task GetFlows() + { + var flows = await _db.ItApprovalFlows.ToListAsync(); + return Ok(flows); + } + + [HttpPost("flows")] + public async Task CreateFlow([FromBody] ItApprovalFlow flow) + { + if (string.IsNullOrWhiteSpace(flow.FlowName)) + return BadRequest(new { message = "Flow name is required" }); + + _db.ItApprovalFlows.Add(flow); + await _db.SaveChangesAsync(); + return Ok(new { message = "Flow created" }); + } + + [HttpPut("flows/{id}")] + public async Task UpdateFlow(int id, [FromBody] ItApprovalFlow updated) + { + var existing = await _db.ItApprovalFlows.FindAsync(id); + if (existing == null) return NotFound(); + + existing.FlowName = updated.FlowName; + existing.HodUserId = updated.HodUserId; + existing.GroupItHodUserId = updated.GroupItHodUserId; + existing.FinHodUserId = updated.FinHodUserId; + existing.MgmtUserId = updated.MgmtUserId; + + await _db.SaveChangesAsync(); + return Ok(new { message = "Flow updated" }); + } + + [HttpDelete("flows/{id}")] + public async Task DeleteFlow(int id) + { + var flow = await _db.ItApprovalFlows.FindAsync(id); + if (flow == null) return NotFound(); + + bool inUse = await _db.ItRequestStatus.AnyAsync(s => s.ItApprovalFlowId == id); + if (inUse) return BadRequest(new { message = "Cannot delete flow; it is in use." }); + + _db.ItApprovalFlows.Remove(flow); + await _db.SaveChangesAsync(); + return Ok(new { message = "Flow deleted" }); + } + + // -------------------------------------------------------------------- + // DTOs for create + // -------------------------------------------------------------------- + public class CreateItRequestDto + { + // UserId ignored by server (derived from token) + public string StaffName { get; set; } = string.Empty; // client may send, server will override + public string? CompanyName { get; set; } // override + public string? DepartmentName { get; set; } // override + + public string? Designation { get; set; } + public string? Location { get; set; } + public string? EmploymentStatus { get; set; } + public DateTime? ContractEndDate { get; set; } + public DateTime RequiredDate { get; set; } + public string? PhoneExt { get; set; } + + public List Hardware { get; set; } = new(); + public List Emails { get; set; } = new(); + public List OSReqs { get; set; } = new(); + public List Software { get; set; } = new(); + public List SharedPerms { get; set; } = new(); + + public int? EditWindowHours { get; set; } + public bool? SendNow { get; set; } + } + + public class HardwareDto + { + public string Category { get; set; } = ""; + public string? Purpose { get; set; } + public string? Justification { get; set; } + public string? OtherDescription { get; set; } + } + + // NOTE: as requested, Email UI only collects ProposedAddress. + // Purpose/Notes will be stored as nulls. + public class EmailDto + { + public string? ProposedAddress { get; set; } + } + + public class OsreqDto + { + public string? RequirementText { get; set; } + } + + public class SoftwareDto + { + public string Bucket { get; set; } = ""; // General | Utility | Custom + public string Name { get; set; } = ""; + public string? OtherName { get; set; } + public string? Notes { get; set; } + } + + public class SharedPermDto + { + public string? ShareName { get; set; } + public bool CanRead { get; set; } + public bool CanWrite { get; set; } + public bool CanDelete { get; set; } + public bool CanRemove { get; set; } + } + + + // -------------------------------------------------------------------- + // POST: /ItRequestAPI/create + // Creates a new IT Request and starts an edit window (default 24 hours) + // -------------------------------------------------------------------- + [HttpPost("create")] + public async Task CreateRequest([FromBody] CreateItRequestDto dto) + { + if (dto == null) + return BadRequest(new { message = "Invalid request payload" }); + + if (dto.RequiredDate == default) + return BadRequest(new { message = "RequiredDate is required." }); + + // --- Server-side guard: RequiredDate >= today + 7 (local server date) --- + var minDate = DateTime.Today.AddDays(7); + if (dto.RequiredDate.Date < minDate) + return BadRequest(new { message = $"RequiredDate must be at least {minDate:yyyy-MM-dd} or later." }); + + // --- Server-side guard: SharedPerms cap 6 (trim or reject; here we trim silently) --- + if (dto.SharedPerms != null && dto.SharedPerms.Count > 6) + dto.SharedPerms = dto.SharedPerms.Take(6).ToList(); + + // --- Optional normalization: avoid conflicting hardware choices --- + // If DesktopAllIn is present, drop NotebookAllIn/NotebookOnly + bool hasDesktopAllIn = dto.Hardware?.Any(h => string.Equals(h.Category, "DesktopAllIn", StringComparison.OrdinalIgnoreCase)) == true; + if (hasDesktopAllIn && dto.Hardware != null) + dto.Hardware = dto.Hardware.Where(h => !string.Equals(h.Category, "NotebookAllIn", StringComparison.OrdinalIgnoreCase) + && !string.Equals(h.Category, "NotebookOnly", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + // If NotebookAllIn is present, drop DesktopAllIn/DesktopOnly + bool hasNotebookAllIn = dto.Hardware?.Any(h => string.Equals(h.Category, "NotebookAllIn", StringComparison.OrdinalIgnoreCase)) == true; + if (hasNotebookAllIn && dto.Hardware != null) + dto.Hardware = dto.Hardware.Where(h => !string.Equals(h.Category, "DesktopAllIn", StringComparison.OrdinalIgnoreCase) + && !string.Equals(h.Category, "DesktopOnly", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + try + { + // --- Validate and get current user ----------------------------------------------------- + var userIdStr = _userManager.GetUserId(User); + if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId)) + return Unauthorized("Invalid user"); + + // --- Lookup user snapshot -------------------------------------------------------------- + var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == currentUserId); + if (user == null) return NotFound("User not found."); + + var dept = await _db.Departments.FirstOrDefaultAsync(d => d.DepartmentId == user.departmentId); + var comp = dept != null + ? await _db.Companies.FirstOrDefaultAsync(c => c.CompanyId == dept.CompanyId) + : null; + + var snapStaffName = user.FullName ?? user.UserName ?? "(unknown)"; + var snapDepartmentName = dept?.DepartmentName ?? ""; + var snapCompanyName = comp?.CompanyName ?? ""; + + // --- Determine edit window hours ------------------------------------------------------- + int windowHours = 24; + if (dto.EditWindowHours.HasValue) + windowHours = Math.Max(1, Math.Min(dto.EditWindowHours.Value, 24 * 14)); + else if (int.TryParse(Request.Headers["X-Edit-Window-Hours"], out var h)) + windowHours = Math.Max(1, Math.Min(h, 24 * 14)); + else if (int.TryParse(Request.Query["editWindowHours"], out var q)) + windowHours = Math.Max(1, Math.Min(q, 24 * 14)); + + var nowUtc = DateTime.UtcNow; + + // --- Insert main request --------------------------------------------------------------- + var request = new ItRequest + { + UserId = currentUserId, + + StaffName = snapStaffName, + DepartmentName = snapDepartmentName, + CompanyName = snapCompanyName, + + Designation = dto.Designation, + Location = dto.Location, + EmploymentStatus = dto.EmploymentStatus, + ContractEndDate = dto.ContractEndDate, + + RequiredDate = dto.RequiredDate, + PhoneExt = dto.PhoneExt, + SubmitDate = DateTime.Now, + + FirstSubmittedAt = nowUtc, + EditableUntil = nowUtc.AddHours(windowHours), + IsLockedForEdit = false + }; + + _db.ItRequests.Add(request); + await _db.SaveChangesAsync(); + + // --- Children -------------------------------------------------------------------------- + if (dto.Hardware != null) + foreach (var hw in dto.Hardware) + _db.ItRequestHardwares.Add(new ItRequestHardware + { + ItRequestId = request.ItRequestId, + Category = hw.Category, + Purpose = hw.Purpose, + Justification = hw.Justification, + OtherDescription = hw.OtherDescription + }); + + if (dto.Emails != null) + foreach (var em in dto.Emails) + _db.ItRequestEmails.Add(new ItRequestEmail + { + ItRequestId = request.ItRequestId, + Purpose = null, + ProposedAddress = em.ProposedAddress, + Notes = null + }); + + if (dto.OSReqs != null) + foreach (var os in dto.OSReqs) + _db.ItRequestOsRequirement.Add(new ItRequestOsRequirement + { + ItRequestId = request.ItRequestId, + RequirementText = os.RequirementText + }); + + if (dto.Software != null) + foreach (var sw in dto.Software) + _db.ItRequestSoftware.Add(new ItRequestSoftware + { + ItRequestId = request.ItRequestId, + Bucket = sw.Bucket, + Name = sw.Name, + OtherName = sw.OtherName, + Notes = sw.Notes + }); + + if (dto.SharedPerms != null) + foreach (var sp in dto.SharedPerms) + _db.ItRequestSharedPermission.Add(new ItRequestSharedPermission + { + ItRequestId = request.ItRequestId, + ShareName = sp.ShareName, + CanRead = sp.CanRead, + CanWrite = sp.CanWrite, + CanDelete = sp.CanDelete, + CanRemove = sp.CanRemove + }); + + await _db.SaveChangesAsync(); + + // --- Default approval flow ------------------------------------------------------------- + var flow = await _db.ItApprovalFlows.FirstOrDefaultAsync(); + if (flow == null) + return BadRequest(new { message = "No IT Approval Flow configured" }); + + var status = new ItRequestStatus + { + ItRequestId = request.ItRequestId, + ItApprovalFlowId = flow.ItApprovalFlowId, + HodStatus = "Pending", + GitHodStatus = "Pending", + FinHodStatus = "Pending", + MgmtStatus = "Pending", + OverallStatus = "Draft" + }; + + _db.ItRequestStatus.Add(status); + await _db.SaveChangesAsync(); + + if (dto.SendNow == true) + { + request.IsLockedForEdit = true; + status.OverallStatus = "Pending"; + await _db.SaveChangesAsync(); + } + + return Ok(new + { + message = "IT request created successfully. You can edit it for a limited time.", + requestId = request.ItRequestId, + statusId = status.StatusId, + editableUntil = request.EditableUntil, + editWindowHours = windowHours, + overallStatus = status.OverallStatus + }); + } + catch (UnauthorizedAccessException ex) + { + return Unauthorized(ex.Message); + } + catch (Exception ex) + { + return StatusCode(500, new { message = "Error creating IT request", detail = ex.Message }); + } + } + + + + private static bool ShouldLock(ItRequest r) => + !r.IsLockedForEdit && r.EditableUntil.HasValue && DateTime.UtcNow > r.EditableUntil.Value; + + private async Task LockOnlyAsync(ItRequest r) + { + if (r.IsLockedForEdit) return; + r.IsLockedForEdit = true; + await _db.SaveChangesAsync(); + } + + + // read current edit state (also lazy-lock if expired) + [HttpGet("editWindow/{statusId:int}")] + public async Task GetEditWindow(int statusId) + { + var s = await _db.ItRequestStatus.Include(x => x.Request).FirstOrDefaultAsync(x => x.StatusId == statusId); + if (s == null) return NotFound("Status not found"); + var r = s.Request; + + if (ShouldLock(r)) await LockOnlyAsync(r); + + var remaining = r.EditableUntil.HasValue ? Math.Max(0, (int)(r.EditableUntil.Value - DateTime.UtcNow).TotalSeconds) : 0; + return Ok(new + { + overallStatus = s.OverallStatus ?? "Draft", + isEditable = !r.IsLockedForEdit && (s.OverallStatus ?? "Draft") == "Draft" && remaining > 0, + remainingSeconds = remaining, + editableUntil = r.EditableUntil + }); + } + + // PUT: full draft update (same shape as create) + public class UpdateDraftDto : CreateItRequestDto { public int StatusId { get; set; } } + + [HttpPut("edit/{statusId:int}")] + public async Task UpdateDraft(int statusId, [FromBody] UpdateDraftDto dto) + { + int currentUserId; try { currentUserId = GetCurrentUserIdOrThrow(); } catch (Exception ex) { return Unauthorized(ex.Message); } + + var s = await _db.ItRequestStatus.Include(x => x.Request).FirstOrDefaultAsync(x => x.StatusId == statusId); + if (s == null) return NotFound("Status not found"); + var r = s.Request; + + if (r.UserId != currentUserId) return Unauthorized("You can only edit your own request"); + if (ShouldLock(r)) await LockOnlyAsync(r); + if (r.IsLockedForEdit || (s.OverallStatus ?? "Draft") != "Draft") return StatusCode(423, "Edit window closed."); + + // --- Server-side guard: RequiredDate >= today + 7 --- + var minDate = DateTime.Today.AddDays(7); + if (dto.RequiredDate.Date < minDate) + return BadRequest(new { message = $"RequiredDate must be at least {minDate:yyyy-MM-dd} or later." }); + + // --- Cap shared perms to 6 --- + if (dto.SharedPerms != null && dto.SharedPerms.Count > 6) + dto.SharedPerms = dto.SharedPerms.Take(6).ToList(); + + // --- Optional normalization of conflicting hardware --- + bool hasDesktopAllIn = dto.Hardware?.Any(h => string.Equals(h.Category, "DesktopAllIn", StringComparison.OrdinalIgnoreCase)) == true; + if (hasDesktopAllIn && dto.Hardware != null) + dto.Hardware = dto.Hardware.Where(h => !string.Equals(h.Category, "NotebookAllIn", StringComparison.OrdinalIgnoreCase) + && !string.Equals(h.Category, "NotebookOnly", StringComparison.OrdinalIgnoreCase)).ToList(); + bool hasNotebookAllIn = dto.Hardware?.Any(h => string.Equals(h.Category, "NotebookAllIn", StringComparison.OrdinalIgnoreCase)) == true; + if (hasNotebookAllIn && dto.Hardware != null) + dto.Hardware = dto.Hardware.Where(h => !string.Equals(h.Category, "DesktopAllIn", StringComparison.OrdinalIgnoreCase) + && !string.Equals(h.Category, "DesktopOnly", StringComparison.OrdinalIgnoreCase)).ToList(); + + // update simple fields + r.Designation = dto.Designation; + r.Location = dto.Location; + r.EmploymentStatus = dto.EmploymentStatus; + r.ContractEndDate = dto.ContractEndDate; + r.RequiredDate = dto.RequiredDate; + r.PhoneExt = dto.PhoneExt; + + // replace children + var id = r.ItRequestId; + + _db.ItRequestHardwares.RemoveRange(_db.ItRequestHardwares.Where(x => x.ItRequestId == id)); + if (dto.Hardware != null) + foreach (var x in dto.Hardware) + _db.ItRequestHardwares.Add(new ItRequestHardware { ItRequestId = id, Category = x.Category, Purpose = x.Purpose, Justification = x.Justification, OtherDescription = x.OtherDescription }); + + _db.ItRequestEmails.RemoveRange(_db.ItRequestEmails.Where(x => x.ItRequestId == id)); + if (dto.Emails != null) + foreach (var x in dto.Emails) + _db.ItRequestEmails.Add(new ItRequestEmail { ItRequestId = id, Purpose = null, ProposedAddress = x.ProposedAddress, Notes = null }); + + _db.ItRequestOsRequirement.RemoveRange(_db.ItRequestOsRequirement.Where(x => x.ItRequestId == id)); + if (dto.OSReqs != null) + foreach (var x in dto.OSReqs) + _db.ItRequestOsRequirement.Add(new ItRequestOsRequirement { ItRequestId = id, RequirementText = x.RequirementText }); + + _db.ItRequestSoftware.RemoveRange(_db.ItRequestSoftware.Where(x => x.ItRequestId == id)); + if (dto.Software != null) + foreach (var x in dto.Software) + _db.ItRequestSoftware.Add(new ItRequestSoftware { ItRequestId = id, Bucket = x.Bucket, Name = x.Name, OtherName = x.OtherName, Notes = x.Notes }); + + _db.ItRequestSharedPermission.RemoveRange(_db.ItRequestSharedPermission.Where(x => x.ItRequestId == id)); + if (dto.SharedPerms != null) + foreach (var x in dto.SharedPerms) + _db.ItRequestSharedPermission.Add(new ItRequestSharedPermission { ItRequestId = id, ShareName = x.ShareName, CanRead = x.CanRead, CanWrite = x.CanWrite, CanDelete = x.CanDelete, CanRemove = x.CanRemove }); + + await _db.SaveChangesAsync(); + + var remaining = r.EditableUntil.HasValue ? Math.Max(0, (int)(r.EditableUntil.Value - DateTime.UtcNow).TotalSeconds) : 0; + return Ok(new { message = "Draft saved", remainingSeconds = remaining }); + } + + + // POST: send early + [HttpPost("sendNow/{statusId:int}")] + public async Task SendNow(int statusId) + { + int uid; + try { uid = GetCurrentUserIdOrThrow(); } + catch (Exception ex) { return Unauthorized(ex.Message); } + + var s = await _db.ItRequestStatus + .Include(x => x.Request) + .FirstOrDefaultAsync(x => x.StatusId == statusId); + + if (s == null) return NotFound("Status not found"); + if (s.Request.UserId != uid) return Unauthorized("Only owner can send"); + + // Allow SendNow as long as it’s still a Draft (even if the edit window already locked it). + if ((s.OverallStatus ?? "Draft") != "Draft") + return BadRequest(new { message = "Already sent or not in Draft." }); + + // Ensure locked, then make it visible to approvers. + if (!s.Request.IsLockedForEdit) s.Request.IsLockedForEdit = true; + s.OverallStatus = "Pending"; + + await _db.SaveChangesAsync(); + + return Ok(new { message = "Sent to approval", overallStatus = s.OverallStatus }); + } + + + + + // -------------------------------------------------------------------- + // GET: /ItRequestAPI/request/{statusId} + // Adds lazy-lock and returns full request details for Edit page + // -------------------------------------------------------------------- + [HttpGet("request/{statusId}")] + public async Task GetRequestDetail(int statusId) + { + // auth + var userIdStr = _userManager.GetUserId(User); + if (string.IsNullOrEmpty(userIdStr) || !int.TryParse(userIdStr, out int currentUserId)) + return Unauthorized("Invalid user"); + + // load status + children + var status = await _db.ItRequestStatus + .Include(s => s.Request).ThenInclude(r => r!.Hardware) + .Include(s => s.Request).ThenInclude(r => r!.Emails) + .Include(s => s.Request).ThenInclude(r => r!.OsRequirements) + .Include(s => s.Request).ThenInclude(r => r!.Software) + .Include(s => s.Request).ThenInclude(r => r!.SharedPermissions) + .Include(s => s.Flow) + .FirstOrDefaultAsync(s => s.StatusId == statusId); + + if (status == null) return NotFound("Request not found"); + + // lazy lock if the edit window has expired + async Task LazyLockIfExpired(ItRequestStatus s) + { + var r = s.Request; + var expired = !r.IsLockedForEdit && r.EditableUntil.HasValue && DateTime.UtcNow > r.EditableUntil.Value; + if (!expired) return; + + r.IsLockedForEdit = true; + await _db.SaveChangesAsync(); + } + await LazyLockIfExpired(status); + + // remaining seconds for countdown + var remaining = status.Request.EditableUntil.HasValue + ? Math.Max(0, (int)(status.Request.EditableUntil.Value - DateTime.UtcNow).TotalSeconds) + : 0; + + // role + canApprove (unchanged) + string role = + status.Flow.HodUserId == currentUserId ? "HOD" : + status.Flow.GroupItHodUserId == currentUserId ? "GIT_HOD" : + status.Flow.FinHodUserId == currentUserId ? "FIN_HOD" : + status.Flow.MgmtUserId == currentUserId ? "MGMT" : + "VIEWER"; + + string currentUserStatus = "N/A"; + bool canApprove = false; + if (string.Equals(status.OverallStatus, "Cancelled", StringComparison.OrdinalIgnoreCase) || + string.Equals(status.OverallStatus ?? "Draft", "Draft", StringComparison.OrdinalIgnoreCase)) + { + canApprove = false; + } + + if (role == "HOD") + { + currentUserStatus = status.HodStatus ?? "Pending"; + canApprove = currentUserStatus == "Pending"; + } + else if (role == "GIT_HOD") + { + currentUserStatus = status.GitHodStatus ?? "Pending"; + canApprove = currentUserStatus == "Pending" && status.HodStatus == "Approved"; + } + else if (role == "FIN_HOD") + { + currentUserStatus = status.FinHodStatus ?? "Pending"; + canApprove = currentUserStatus == "Pending" && + status.HodStatus == "Approved" && + status.GitHodStatus == "Approved"; + } + else if (role == "MGMT") + { + currentUserStatus = status.MgmtStatus ?? "Pending"; + canApprove = currentUserStatus == "Pending" && + status.HodStatus == "Approved" && + status.GitHodStatus == "Approved" && + status.FinHodStatus == "Approved"; + } + // No actions allowed on cancelled requests + + + + // RESPONSE: include full main fields for Edit page + return Ok(new + { + // full request fields you need to prefill + request = new + { + status.Request.ItRequestId, + status.Request.StaffName, + status.Request.DepartmentName, + status.Request.CompanyName, + status.Request.Designation, + status.Request.Location, + status.Request.EmploymentStatus, + status.Request.ContractEndDate, + status.Request.RequiredDate, + status.Request.PhoneExt, + status.Request.SubmitDate + }, + + approverRole = role, + + hardware = status.Request.Hardware?.Select(h => new { + h.Id, + Category = h.Category ?? "", + Purpose = h.Purpose ?? "", + Justification = h.Justification ?? "", + OtherDescription = h.OtherDescription ?? "" + }), + + emails = status.Request.Emails?.Select(e => new { + e.Id, + Purpose = e.Purpose ?? "", + ProposedAddress = e.ProposedAddress ?? "", + Notes = e.Notes ?? "" + }), + + osreqs = status.Request.OsRequirements?.Select(o => new { + o.Id, + RequirementText = o.RequirementText ?? "" + }), + + software = status.Request.Software?.Select(sw => new { + sw.Id, + Bucket = sw.Bucket ?? "", + Name = sw.Name ?? "", + OtherName = sw.OtherName ?? "", + Notes = sw.Notes ?? "" + }), + + sharedPerms = status.Request.SharedPermissions?.Select(sp => new { + sp.Id, + ShareName = sp.ShareName ?? "", + CanRead = sp.CanRead, + CanWrite = sp.CanWrite, + CanDelete = sp.CanDelete, + CanRemove = sp.CanRemove + }), + + status = new + { + hodStatus = status.HodStatus ?? "Pending", + gitHodStatus = status.GitHodStatus ?? "Pending", + finHodStatus = status.FinHodStatus ?? "Pending", + mgmtStatus = status.MgmtStatus ?? "Pending", + + hodSubmitDate = status.HodSubmitDate, + gitHodSubmitDate = status.GitHodSubmitDate, + finHodSubmitDate = status.FinHodSubmitDate, + mgmtSubmitDate = status.MgmtSubmitDate, + + overallStatus = status.OverallStatus ?? "Pending", + canApprove = canApprove, + currentUserStatus = currentUserStatus + }, + + // edit window info for the Edit page + edit = new + { + overallStatus = status.OverallStatus ?? "Draft", + isEditable = !status.Request.IsLockedForEdit && (status.OverallStatus ?? "Draft") == "Draft" && remaining > 0, + remainingSeconds = remaining, + editableUntil = status.Request.EditableUntil + } + }); + } + + + + // -------------------------------------------------------------------- + // GET: /ItRequestAPI/lookups + // -------------------------------------------------------------------- + [HttpGet("lookups")] + public IActionResult Lookups() + { + var hardwareCategories = new[] { + "DesktopAllIn","NotebookAllIn","DesktopOnly","NotebookOnly","NotebookBattery","PowerAdapter","Mouse","ExternalHDD","Other" + }; + var hardwarePurposes = new[] { "NewRecruitment", "Replacement", "Additional" }; + var employmentStatuses = new[] { "Permanent", "Contract", "Temp", "New Staff" }; + var softwareBuckets = new[] { "General", "Utility", "Custom" }; + + return Ok(new + { + hardwareCategories, + hardwarePurposes, + employmentStatuses, + softwareBuckets + }); + } + + + // -------------------------------------------------------------------- + // POST: /ItRequestAPI/cancel + public class CancelDto { public int RequestId { get; set; } public string? Reason { get; set; } } + + [HttpPost("cancel")] + public async Task CancelRequest([FromBody] CancelDto dto) + { + int userId; + try { userId = GetCurrentUserIdOrThrow(); } + catch (UnauthorizedAccessException ex) { return Unauthorized(ex.Message); } + + var req = await _db.ItRequests.FirstOrDefaultAsync(r => r.ItRequestId == dto.RequestId); + if (req == null) return NotFound("Request not found"); + if (req.UserId != userId) return Unauthorized("You can only cancel your own requests"); + + var s = await _db.ItRequestStatus.FirstOrDefaultAsync(x => x.ItRequestId == req.ItRequestId); + if (s == null) return NotFound("Status row not found"); + + var overall = s.OverallStatus ?? "Draft"; + + // Allow cancel: + // - Draft (always) + // - Pending (only if ALLOW_PENDING_CANCEL == true) and no approvals have started + bool allPending = + (s.HodStatus ?? "Pending") == "Pending" && + (s.GitHodStatus ?? "Pending") == "Pending" && + (s.FinHodStatus ?? "Pending") == "Pending" && + (s.MgmtStatus ?? "Pending") == "Pending"; + + bool canCancelNow = + overall == "Draft" || + (ALLOW_PENDING_CANCEL && overall == "Pending" && allPending); + + if (!canCancelNow) + return BadRequest(new { message = "Cannot cancel after approvals have started or once decided." }); + + s.OverallStatus = "Cancelled"; + req.IsLockedForEdit = true; + // TODO: persist dto.Reason if you add a column + + await _db.SaveChangesAsync(); + return Ok(new { message = "Request cancelled", requestId = req.ItRequestId, statusId = s.StatusId, overallStatus = s.OverallStatus }); + } + + + } +} diff --git a/Controllers/API/OvertimeAPI.cs b/Controllers/API/OvertimeAPI.cs index a1c5899..ce06f0d 100644 --- a/Controllers/API/OvertimeAPI.cs +++ b/Controllers/API/OvertimeAPI.cs @@ -29,7 +29,7 @@ using System.Threading.Tasks; using static PSTW_CentralSystem.Areas.OTcalculate.Models.OtRegisterModel; using static System.Collections.Specialized.BitVector32; - + namespace PSTW_CentralSystem.Controllers.API { [ApiController] @@ -865,317 +865,317 @@ namespace PSTW_CentralSystem.Controllers.API #endregion #region Ot Register - [HttpGet("GetStationsByDepartment")] - public async Task GetStationsByDepartment([FromQuery] int? departmentId) - { - if (!departmentId.HasValue) + [HttpGet("GetStationsByDepartment")] + public async Task GetStationsByDepartment([FromQuery] int? departmentId) { - _logger.LogWarning("GetStationsByDepartment called without a departmentId."); - return Ok(new List()); - } - - var stations = await _centralDbContext.Stations - .Where(s => s.DepartmentId == departmentId.Value) - .Select(s => new + if (!departmentId.HasValue) { - s.StationId, - StationName = s.StationName ?? "Unnamed Station" - }) - .ToListAsync(); - - return Ok(stations); - } - - - [HttpPost("AddOvertime")] - public async Task AddOvertimeAsync([FromBody] OvertimeRequestDto request) - { - _logger.LogInformation("AddOvertimeAsync called."); - _logger.LogInformation("Received request: {@Request}", request); - - if (request == null) - { - _logger.LogError("Request is null."); - return BadRequest("Invalid data."); - } - - try - { - if (request.UserId == 0) - { - _logger.LogWarning("No user ID provided."); - return BadRequest("User ID is required."); + _logger.LogWarning("GetStationsByDepartment called without a departmentId."); + return Ok(new List()); } - var user = await _userManager.FindByIdAsync(request.UserId.ToString()); - if (user == null) - { - _logger.LogError("User with ID {UserId} not found for overtime submission.", request.UserId); - return Unauthorized("User not found."); - } - - var userRoles = await _userManager.GetRolesAsync(user); - var isSuperAdmin = userRoles.Contains("SuperAdmin"); - var isSystemAdmin = userRoles.Contains("SystemAdmin"); - - var userWithDepartment = await _centralDbContext.Users - .Include(u => u.Department) - .FirstOrDefaultAsync(u => u.Id == request.UserId); - - int? userDepartmentId = userWithDepartment?.Department?.DepartmentId; - - bool stationRequired = false; - if (userDepartmentId == 2 || userDepartmentId == 3) - { - stationRequired = true; - } - else if (isSuperAdmin || isSystemAdmin) - { - stationRequired = true; - } - - if (stationRequired && (!request.StationId.HasValue || request.StationId.Value <= 0)) - { - return BadRequest("A station must be selected."); - } - - TimeSpan? officeFrom = string.IsNullOrEmpty(request.OfficeFrom) ? (TimeSpan?)null : TimeSpan.Parse(request.OfficeFrom); - TimeSpan? officeTo = string.IsNullOrEmpty(request.OfficeTo) ? (TimeSpan?)null : TimeSpan.Parse(request.OfficeTo); - TimeSpan? afterFrom = string.IsNullOrEmpty(request.AfterFrom) ? (TimeSpan?)null : TimeSpan.Parse(request.AfterFrom); - TimeSpan? afterTo = string.IsNullOrEmpty(request.AfterTo) ? (TimeSpan?)null : TimeSpan.Parse(request.AfterTo); - - if ((officeFrom != null && officeTo == null) || (officeFrom == null && officeTo != null)) - { - return BadRequest("Both Office From and To times must be provided if one is entered."); - } - if ((afterFrom != null && afterTo == null) || (afterFrom == null && afterTo != null)) - { - return BadRequest("Both After Office From and To times must be provided if one is entered."); - } - - TimeSpan minAllowedFromMidnightTo = new TimeSpan(16, 30, 0); - TimeSpan maxAllowedFromMidnightTo = new TimeSpan(23, 30, 0); - - if (officeFrom.HasValue && officeTo.HasValue) - { - if (officeTo == TimeSpan.Zero) + var stations = await _centralDbContext.Stations + .Where(s => s.DepartmentId == departmentId.Value) + .Select(s => new { - if (officeFrom == TimeSpan.Zero) - { - return BadRequest("Invalid Office Hour Time: 'From' and 'To' cannot both be 00:00 (midnight)."); - } - - if (officeFrom.Value < minAllowedFromMidnightTo || officeFrom.Value > maxAllowedFromMidnightTo) - { - return BadRequest("Invalid Office Hour Time: If 'To' is 12:00 am (00:00), 'From' must start between end of your flexi hour to 11:30 pm on the same day."); - } - } - else if (officeTo <= officeFrom) - { - return BadRequest("Invalid Office Hour Time: 'To' time must be later than 'From' time (same day only)."); - } - } - - if (afterFrom.HasValue && afterTo.HasValue) - { - if (afterTo == TimeSpan.Zero) - { - if (afterFrom == TimeSpan.Zero) - { - return BadRequest("Invalid After Office Hour Time: 'From' and 'To' cannot both be 00:00 (midnight)."); - } - - if (afterFrom.Value < minAllowedFromMidnightTo || afterFrom.Value > maxAllowedFromMidnightTo) - { - return BadRequest("Invalid After Office Hour Time: If 'To' is 12:00 am (00:00), 'From' must start between end of your flexi hour to 11:30 pm on the same day."); - } - } - else if (afterTo <= afterFrom) - { - return BadRequest("Invalid After Office Hour Time: 'To' time must be later than 'From' time (same day only)."); - } - } - - if ((officeFrom == null && officeTo == null) && (afterFrom == null && afterTo == null)) - { - return BadRequest("Please enter either Office Hours or After Office Hours."); - } - - // Convert the request date to a DateOnly for comparison - DateOnly requestDate = DateOnly.FromDateTime(request.OtDate); - - // Fetch existing overtime records for the same user and date - var existingOvertimeRecords = await _centralDbContext.Otregisters - .Where(o => o.UserId == request.UserId && DateOnly.FromDateTime(o.OtDate) == requestDate) + s.StationId, + StationName = s.StationName ?? "Unnamed Station" + }) .ToListAsync(); - // Check for overlaps with existing records - foreach (var existingRecord in existingOvertimeRecords) - { - // Convert existing record times to actual TimeSpan objects - var existingOfficeFrom = existingRecord.OfficeFrom; - var existingOfficeTo = existingRecord.OfficeTo; - var existingAfterFrom = existingRecord.AfterFrom; - var existingAfterTo = existingRecord.AfterTo; + return Ok(stations); + } - // Function to check for overlap between two time ranges - Func CheckOverlap = - (newStart, newEnd, existingStart, existingEnd) => + + [HttpPost("AddOvertime")] + public async Task AddOvertimeAsync([FromBody] OvertimeRequestDto request) + { + _logger.LogInformation("AddOvertimeAsync called."); + _logger.LogInformation("Received request: {@Request}", request); + + if (request == null) + { + _logger.LogError("Request is null."); + return BadRequest("Invalid data."); + } + + try + { + if (request.UserId == 0) + { + _logger.LogWarning("No user ID provided."); + return BadRequest("User ID is required."); + } + + var user = await _userManager.FindByIdAsync(request.UserId.ToString()); + if (user == null) + { + _logger.LogError("User with ID {UserId} not found for overtime submission.", request.UserId); + return Unauthorized("User not found."); + } + + var userRoles = await _userManager.GetRolesAsync(user); + var isSuperAdmin = userRoles.Contains("SuperAdmin"); + var isSystemAdmin = userRoles.Contains("SystemAdmin"); + + var userWithDepartment = await _centralDbContext.Users + .Include(u => u.Department) + .FirstOrDefaultAsync(u => u.Id == request.UserId); + + int? userDepartmentId = userWithDepartment?.Department?.DepartmentId; + + bool stationRequired = false; + if (userDepartmentId == 2 || userDepartmentId == 3) + { + stationRequired = true; + } + else if (isSuperAdmin || isSystemAdmin) + { + stationRequired = true; + } + + if (stationRequired && (!request.StationId.HasValue || request.StationId.Value <= 0)) + { + return BadRequest("A station must be selected."); + } + + TimeSpan? officeFrom = string.IsNullOrEmpty(request.OfficeFrom) ? (TimeSpan?)null : TimeSpan.Parse(request.OfficeFrom); + TimeSpan? officeTo = string.IsNullOrEmpty(request.OfficeTo) ? (TimeSpan?)null : TimeSpan.Parse(request.OfficeTo); + TimeSpan? afterFrom = string.IsNullOrEmpty(request.AfterFrom) ? (TimeSpan?)null : TimeSpan.Parse(request.AfterFrom); + TimeSpan? afterTo = string.IsNullOrEmpty(request.AfterTo) ? (TimeSpan?)null : TimeSpan.Parse(request.AfterTo); + + if ((officeFrom != null && officeTo == null) || (officeFrom == null && officeTo != null)) + { + return BadRequest("Both Office From and To times must be provided if one is entered."); + } + if ((afterFrom != null && afterTo == null) || (afterFrom == null && afterTo != null)) + { + return BadRequest("Both After Office From and To times must be provided if one is entered."); + } + + TimeSpan minAllowedFromMidnightTo = new TimeSpan(16, 30, 0); + TimeSpan maxAllowedFromMidnightTo = new TimeSpan(23, 30, 0); + + if (officeFrom.HasValue && officeTo.HasValue) + { + if (officeTo == TimeSpan.Zero) { - if (!newStart.HasValue || !newEnd.HasValue || !existingStart.HasValue || !existingEnd.HasValue) + if (officeFrom == TimeSpan.Zero) { - return false; // No overlap if either range is incomplete + return BadRequest("Invalid Office Hour Time: 'From' and 'To' cannot both be 00:00 (midnight)."); } - // Handle midnight (00:00) as end of day (24:00) for comparison purposes - TimeSpan adjustedNewEnd = (newEnd == TimeSpan.Zero && newStart != TimeSpan.Zero) ? TimeSpan.FromHours(24) : newEnd.Value; - TimeSpan adjustedExistingEnd = (existingEnd == TimeSpan.Zero && existingStart != TimeSpan.Zero) ? TimeSpan.FromHours(24) : existingEnd.Value; - - - // Check for overlap: - // New range starts before existing range ends AND - // New range ends after existing range starts - return (newStart.Value < adjustedExistingEnd && adjustedNewEnd > existingStart.Value); - }; - - // Check for overlap between new Office Hours and existing Office Hours - if (CheckOverlap(officeFrom, officeTo, existingOfficeFrom, existingOfficeTo)) - { - return BadRequest("Your Office Hours entry overlaps with another record on this date. Kindly adjust your time."); + if (officeFrom.Value < minAllowedFromMidnightTo || officeFrom.Value > maxAllowedFromMidnightTo) + { + return BadRequest("Invalid Office Hour Time: If 'To' is 12:00 am (00:00), 'From' must start between end of your flexi hour to 11:30 pm on the same day."); + } + } + else if (officeTo <= officeFrom) + { + return BadRequest("Invalid Office Hour Time: 'To' time must be later than 'From' time (same day only)."); + } } - // Check for overlap between new After Office Hours and existing After Office Hours - if (CheckOverlap(afterFrom, afterTo, existingAfterFrom, existingAfterTo)) + if (afterFrom.HasValue && afterTo.HasValue) { - return BadRequest("Your Office Hours entry overlaps with another record on this date. Kindly adjust your time."); + if (afterTo == TimeSpan.Zero) + { + if (afterFrom == TimeSpan.Zero) + { + return BadRequest("Invalid After Office Hour Time: 'From' and 'To' cannot both be 00:00 (midnight)."); + } + + if (afterFrom.Value < minAllowedFromMidnightTo || afterFrom.Value > maxAllowedFromMidnightTo) + { + return BadRequest("Invalid After Office Hour Time: If 'To' is 12:00 am (00:00), 'From' must start between end of your flexi hour to 11:30 pm on the same day."); + } + } + else if (afterTo <= afterFrom) + { + return BadRequest("Invalid After Office Hour Time: 'To' time must be later than 'From' time (same day only)."); + } } - // Check for overlap between new Office Hours and existing After Office Hours - if (CheckOverlap(officeFrom, officeTo, existingAfterFrom, existingAfterTo)) + if ((officeFrom == null && officeTo == null) && (afterFrom == null && afterTo == null)) { - return BadRequest("Your Office Hours entry overlaps with another record on this date. Kindly adjust your time."); + return BadRequest("Please enter either Office Hours or After Office Hours."); } - // Check for overlap between new After Office Hours and existing Office Hours - if (CheckOverlap(afterFrom, afterTo, existingOfficeFrom, existingOfficeTo)) + // Convert the request date to a DateOnly for comparison + DateOnly requestDate = DateOnly.FromDateTime(request.OtDate); + + // Fetch existing overtime records for the same user and date + var existingOvertimeRecords = await _centralDbContext.Otregisters + .Where(o => o.UserId == request.UserId && DateOnly.FromDateTime(o.OtDate) == requestDate) + .ToListAsync(); + + // Check for overlaps with existing records + foreach (var existingRecord in existingOvertimeRecords) { - return BadRequest("Your Office Hours entry overlaps with another record on this date. Kindly adjust your time."); + // Convert existing record times to actual TimeSpan objects + var existingOfficeFrom = existingRecord.OfficeFrom; + var existingOfficeTo = existingRecord.OfficeTo; + var existingAfterFrom = existingRecord.AfterFrom; + var existingAfterTo = existingRecord.AfterTo; + + // Function to check for overlap between two time ranges + Func CheckOverlap = + (newStart, newEnd, existingStart, existingEnd) => + { + if (!newStart.HasValue || !newEnd.HasValue || !existingStart.HasValue || !existingEnd.HasValue) + { + return false; // No overlap if either range is incomplete + } + + // Handle midnight (00:00) as end of day (24:00) for comparison purposes + TimeSpan adjustedNewEnd = (newEnd == TimeSpan.Zero && newStart != TimeSpan.Zero) ? TimeSpan.FromHours(24) : newEnd.Value; + TimeSpan adjustedExistingEnd = (existingEnd == TimeSpan.Zero && existingStart != TimeSpan.Zero) ? TimeSpan.FromHours(24) : existingEnd.Value; + + + // Check for overlap: + // New range starts before existing range ends AND + // New range ends after existing range starts + return (newStart.Value < adjustedExistingEnd && adjustedNewEnd > existingStart.Value); + }; + + // Check for overlap between new Office Hours and existing Office Hours + if (CheckOverlap(officeFrom, officeTo, existingOfficeFrom, existingOfficeTo)) + { + return BadRequest("Your Office Hours entry overlaps with another record on this date. Kindly adjust your time."); + } + + // Check for overlap between new After Office Hours and existing After Office Hours + if (CheckOverlap(afterFrom, afterTo, existingAfterFrom, existingAfterTo)) + { + return BadRequest("Your Office Hours entry overlaps with another record on this date. Kindly adjust your time."); + } + + // Check for overlap between new Office Hours and existing After Office Hours + if (CheckOverlap(officeFrom, officeTo, existingAfterFrom, existingAfterTo)) + { + return BadRequest("Your Office Hours entry overlaps with another record on this date. Kindly adjust your time."); + } + + // Check for overlap between new After Office Hours and existing Office Hours + if (CheckOverlap(afterFrom, afterTo, existingOfficeFrom, existingOfficeTo)) + { + return BadRequest("Your Office Hours entry overlaps with another record on this date. Kindly adjust your time."); + } } - } - var newRecord = new OtRegisterModel - { - OtDate = request.OtDate, - OfficeFrom = officeFrom, - OfficeTo = officeTo, - OfficeBreak = request.OfficeBreak, - AfterFrom = afterFrom, - AfterTo = afterTo, - AfterBreak = request.AfterBreak, - StationId = request.StationId, - OtDescription = request.OtDescription, - OtDays = request.OtDays, - UserId = request.UserId, - StatusId = request.StatusId - }; - - _centralDbContext.Otregisters.Add(newRecord); - await _centralDbContext.SaveChangesAsync(); - - return Ok(new { message = "Overtime registered successfully." }); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error registering overtime for user {UserId}.", request.UserId); - return StatusCode(500, $"An error occurred while saving overtime: {ex.InnerException?.Message ?? ex.Message}"); - } - } - - [HttpGet("GetUserStateAndHolidays/{userId}")] - public async Task GetUserStateAndHolidaysAsync(int userId) - { - try - { - var hrSettings = await _centralDbContext.Hrusersetting - .Include(h => h.State) - .ThenInclude(s => s.Weekends) - .Include(h => h.FlexiHour) - .Where(h => h.UserId == userId) - .FirstOrDefaultAsync(); - - if (hrSettings?.State == null) - { - return Ok(new { state = (object)null, publicHolidays = new List() }); - } - - var publicHolidays = await _centralDbContext.Holidays - .Where(ph => ph.StateId == hrSettings.StateId && ph.HolidayDate.Year == DateTime.Now.Year) - .Select(ph => new { Date = ph.HolidayDate.ToString("yyyy-MM-dd") }) - .ToListAsync(); - - return Ok(new - { - state = new + var newRecord = new OtRegisterModel { - stateId = hrSettings.StateId, - stateName = hrSettings.State?.StateName, - weekendDay = hrSettings.State?.Weekends?.Day, - weekendId = hrSettings.State?.WeekendId, - flexiHour = hrSettings.FlexiHour?.FlexiHour - }, - publicHolidays - }); + OtDate = request.OtDate, + OfficeFrom = officeFrom, + OfficeTo = officeTo, + OfficeBreak = request.OfficeBreak, + AfterFrom = afterFrom, + AfterTo = afterTo, + AfterBreak = request.AfterBreak, + StationId = request.StationId, + OtDescription = request.OtDescription, + OtDays = request.OtDays, + UserId = request.UserId, + StatusId = request.StatusId + }; + + _centralDbContext.Otregisters.Add(newRecord); + await _centralDbContext.SaveChangesAsync(); + + return Ok(new { message = "Overtime registered successfully." }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error registering overtime for user {UserId}.", request.UserId); + return StatusCode(500, $"An error occurred while saving overtime: {ex.InnerException?.Message ?? ex.Message}"); + } } - catch (Exception ex) + + [HttpGet("GetUserStateAndHolidays/{userId}")] + public async Task GetUserStateAndHolidaysAsync(int userId) { - _logger.LogError(ex, "Error fetching user state and public holidays."); - return StatusCode(500, "An error occurred while fetching user state and public holidays."); + try + { + var hrSettings = await _centralDbContext.Hrusersetting + .Include(h => h.State) + .ThenInclude(s => s.Weekends) + .Include(h => h.FlexiHour) + .Where(h => h.UserId == userId) + .FirstOrDefaultAsync(); + + if (hrSettings?.State == null) + { + return Ok(new { state = (object)null, publicHolidays = new List() }); + } + + var publicHolidays = await _centralDbContext.Holidays + .Where(ph => ph.StateId == hrSettings.StateId && ph.HolidayDate.Year == DateTime.Now.Year) + .Select(ph => new { Date = ph.HolidayDate.ToString("yyyy-MM-dd") }) + .ToListAsync(); + + return Ok(new + { + state = new + { + stateId = hrSettings.StateId, + stateName = hrSettings.State?.StateName, + weekendDay = hrSettings.State?.Weekends?.Day, + weekendId = hrSettings.State?.WeekendId, + flexiHour = hrSettings.FlexiHour?.FlexiHour + }, + publicHolidays + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching user state and public holidays."); + return StatusCode(500, "An error occurred while fetching user state and public holidays."); + } } - } - [HttpGet("CheckUserSettings/{userId}")] - public async Task CheckUserSettings(int userId) - { - try + [HttpGet("CheckUserSettings/{userId}")] + public async Task CheckUserSettings(int userId) { - var hrSettings = await _centralDbContext.Hrusersetting - .Where(h => h.UserId == userId) - .FirstOrDefaultAsync(); - - if (hrSettings == null) + try { - return Ok(new { isComplete = false }); - } + var hrSettings = await _centralDbContext.Hrusersetting + .Where(h => h.UserId == userId) + .FirstOrDefaultAsync(); - if (hrSettings.FlexiHourId == null || hrSettings.StateId == null || hrSettings.ApprovalFlowId == null) - { - return Ok(new { isComplete = false }); - } - - var rateSetting = await _centralDbContext.Rates - .Where(r => r.UserId == userId) - .FirstOrDefaultAsync(); - - if (rateSetting == null) - { - return Ok(new { isComplete = false }); - } - else - { - if (rateSetting.RateValue <= 0.00m) + if (hrSettings == null) { return Ok(new { isComplete = false }); } - } - return Ok(new { isComplete = true }); + if (hrSettings.FlexiHourId == null || hrSettings.StateId == null || hrSettings.ApprovalFlowId == null) + { + return Ok(new { isComplete = false }); + } + + var rateSetting = await _centralDbContext.Rates + .Where(r => r.UserId == userId) + .FirstOrDefaultAsync(); + + if (rateSetting == null) + { + return Ok(new { isComplete = false }); + } + else + { + if (rateSetting.RateValue <= 0.00m) + { + return Ok(new { isComplete = false }); + } + } + + return Ok(new { isComplete = true }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking user settings for user {UserId}", userId); + return StatusCode(500, $"An error occurred while checking user settings: {ex.Message}"); + } } - catch (Exception ex) - { - _logger.LogError(ex, "Error checking user settings for user {UserId}", userId); - return StatusCode(500, $"An error occurred while checking user settings: {ex.Message}"); - } - } #endregion #region Ot Records diff --git a/DBContext/CentralSystemContext.cs b/DBContext/CentralSystemContext.cs index 2199aaf..9f73262 100644 --- a/DBContext/CentralSystemContext.cs +++ b/DBContext/CentralSystemContext.cs @@ -7,6 +7,9 @@ using PSTW_CentralSystem.Areas.Inventory.Models; using PSTW_CentralSystem.Areas.OTcalculate.Models; using PSTW_CentralSystem.Models; using System.Text.Json; +using PSTW_CentralSystem.Areas.Bookings.Models; +using PSTW_CentralSystem.Areas.IT.Models; + namespace PSTW_CentralSystem.DBContext { @@ -110,6 +113,31 @@ namespace PSTW_CentralSystem.DBContext public DbSet Approvalflow { get; set; } public DbSet Staffsign { get; set; } + // Bookings + public DbSet Bookings { get; set; } + public DbSet Rooms { get; set; } + public DbSet BookingManager { get; set; } + + // ItForm + public DbSet ItRequests { get; set; } + public DbSet ItRequestHardwares { get; set; } + public DbSet ItRequestEmails { get; set; } + public DbSet ItRequestOsRequirement { get; set; } + public DbSet ItRequestSoftware { get; set; } + public DbSet ItRequestSharedPermission { get; set; } + public DbSet ItApprovalFlows { get; set; } + public DbSet ItRequestStatus { get; set; } + public DbSet ItRequestAssetInfo { get; set; } + public DbSet ItTeamMembers { get; set; } + + + + + + + + + //testingvhjbnadgfsbgdngffdfdsdfdgfdfdg } } diff --git a/Views/Shared/_Layout.cshtml b/Views/Shared/_Layout.cshtml index bc5ad70..1c57ade 100644 --- a/Views/Shared/_Layout.cshtml +++ b/Views/Shared/_Layout.cshtml @@ -582,6 +582,65 @@ + + +