Compare commits
4 Commits
14d013f2f1
...
d002954b69
| Author | SHA1 | Date | |
|---|---|---|---|
| d002954b69 | |||
| 8555a98e66 | |||
| c0e285545e | |||
| 4a1edba2c8 |
93
Areas/Bookings/Controllers/BookingsController.cs
Normal file
93
Areas/Bookings/Controllers/BookingsController.cs
Normal file
@ -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<BookingsController> _logger;
|
||||||
|
|
||||||
|
public BookingsController(CentralSystemContext db, ILogger<BookingsController> 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<bool> 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<bool> AnyManagersAsync()
|
||||||
|
=> _db.BookingManager.AsNoTracking().AnyAsync();
|
||||||
|
|
||||||
|
private async Task<IActionResult?> 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<IActionResult> Room()
|
||||||
|
{
|
||||||
|
var gate = await RequireManagerOrForbidAsync();
|
||||||
|
if (gate is not null) return gate;
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager-only (create/edit room)
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
27
Areas/Bookings/Models/BookingManager.cs
Normal file
27
Areas/Bookings/Models/BookingManager.cs
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace PSTW_CentralSystem.Areas.Bookings.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[Table("booking_managers")]
|
||||||
|
public class BookingManager
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public int BookingManagerId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>FK → aspnetusers(Id) (int)</summary>
|
||||||
|
[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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
88
Areas/Bookings/Models/BookingsModel.cs
Normal file
88
Areas/Bookings/Models/BookingsModel.cs
Normal file
@ -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; }
|
||||||
|
|
||||||
|
/// <summary>FK → aspnetusers(Id) (int)</summary>
|
||||||
|
[Required]
|
||||||
|
public int RequestedByUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>If booking on behalf of someone else; else null.</summary>
|
||||||
|
public int? TargetUserId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Snapshot of org at submission time.</summary>
|
||||||
|
public int? DepartmentId { get; set; } // FK → departments(DepartmentId)
|
||||||
|
public int? CompanyId { get; set; } // FK → companies(CompanyId)
|
||||||
|
|
||||||
|
/// <summary>Room being booked.</summary>
|
||||||
|
[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; }
|
||||||
|
|
||||||
|
/// <summary>Use UTC to avoid TZ headaches; map to DATETIME in MySQL.</summary>
|
||||||
|
[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<ValidationResult> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
721
Areas/Bookings/Views/Bookings/Calendar.cshtml
Normal file
721
Areas/Bookings/Views/Bookings/Calendar.cshtml
Normal file
@ -0,0 +1,721 @@
|
|||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Bookings Calendar";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.calendar-wrap {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,.08);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar .left, .toolbar .right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend span {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-right: 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.free {
|
||||||
|
background: #d9ead3
|
||||||
|
}
|
||||||
|
|
||||||
|
.partial {
|
||||||
|
background: #ffe599
|
||||||
|
}
|
||||||
|
|
||||||
|
.busy {
|
||||||
|
background: #f4cccc
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7,1fr);
|
||||||
|
gap: 6px
|
||||||
|
}
|
||||||
|
|
||||||
|
.dow {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px 0;
|
||||||
|
color: #555
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
min-height: 110px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
cursor: pointer
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.other-month {
|
||||||
|
opacity: .5
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell .daynum {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6px
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
background: #eee;
|
||||||
|
overflow: hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar > div {
|
||||||
|
height: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.free .status-bar > div {
|
||||||
|
background: #b7dfa6
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.partial .status-bar > div {
|
||||||
|
background: #ffd966
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell.busy .status-bar > div {
|
||||||
|
background: #ea9999
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls select {
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e9ecef;
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: auto;
|
||||||
|
width: max-content
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Day Board (separate header row above timeline) ===== */
|
||||||
|
.dayboard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr; /* header row, then scrollable body */
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px 1fr; /* left spacer, right room headers */
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-head-left {
|
||||||
|
background: #fafbfc;
|
||||||
|
border-right: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-head-right {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
/* no scroll; pure header row */
|
||||||
|
.col-header {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-header div {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-right: 1px solid #f1f1f1;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 90px 1fr; /* left time sheet, right bookings sheet */
|
||||||
|
height: 60vh; /* viewport height for scrolling; tweak if needed */
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-time {
|
||||||
|
background: #fafbfc;
|
||||||
|
border-right: 1px solid #e9ecef;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-time::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.time-slot {
|
||||||
|
height: 32px; /* SLOT_PX: must match JS */
|
||||||
|
border-bottom: 1px dashed #eee;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-cols {
|
||||||
|
position: relative;
|
||||||
|
overflow: auto; /* the pane that scrolls bookings vertically */
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas { /* gives the bookings pane its content height */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-grid {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-grid .gcol {
|
||||||
|
border-right: 1px solid #f1f1f1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hline {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: #eee
|
||||||
|
}
|
||||||
|
|
||||||
|
.nowline {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: #ff6;
|
||||||
|
z-index: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking {
|
||||||
|
position: absolute;
|
||||||
|
left: 6px;
|
||||||
|
right: 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #d1fae5;
|
||||||
|
border: 1px solid #a7f3d0;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,.06);
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking .t {
|
||||||
|
font-weight: 700
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking .meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #555
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2 class="mb-0"></h2>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a asp-area="Bookings" asp-controller="Bookings" asp-action="Index" class="btn btn-outline-secondary">Back to List</a>
|
||||||
|
<a asp-area="Bookings" asp-controller="Bookings" asp-action="Create" class="btn btn-primary">Create New</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="alerts"></div>
|
||||||
|
|
||||||
|
<div class="calendar-wrap">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="left">
|
||||||
|
<button id="btnPrev" class="btn btn-sm btn-outline-secondary">◀</button>
|
||||||
|
<button id="btnToday" class="btn btn-sm btn-outline-secondary">Today</button>
|
||||||
|
<button id="btnNext" class="btn btn-sm btn-outline-secondary">▶</button>
|
||||||
|
<strong id="lblMonth" class="ms-2"></strong>
|
||||||
|
</div>
|
||||||
|
<div class="right controls">
|
||||||
|
<select id="ddlRoom" class="form-select form-select-sm" style="min-width:220px">
|
||||||
|
<option value="">All rooms</option>
|
||||||
|
</select>
|
||||||
|
<select id="ddlUser" class="form-select form-select-sm" style="min-width:220px">
|
||||||
|
<option value="">All users</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legend mb-2">
|
||||||
|
<span class="free"></span> Free
|
||||||
|
<span class="partial"></span> Partially booked
|
||||||
|
<span class="busy"></span> Busy
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid mb-2" id="dowRow"></div>
|
||||||
|
<div class="grid" id="calGrid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Day Board Modal ===== -->
|
||||||
|
<div class="modal fade" id="dayBoardModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-fullscreen-lg-down modal-xl modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="board-toolbar w-100">
|
||||||
|
<h5 class="modal-title me-auto" id="boardTitle">Schedule</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="dayboard">
|
||||||
|
<!-- Header row (room names ABOVE the timeline) -->
|
||||||
|
<div class="db-head">
|
||||||
|
<div class="db-head-left"></div>
|
||||||
|
<div class="db-head-right">
|
||||||
|
<div id="colsHeader" class="col-header"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body row (time sheet left, bookings right) -->
|
||||||
|
<div class="db-body">
|
||||||
|
<div class="db-time" id="timeCol"></div>
|
||||||
|
<div class="db-cols" id="colsScroll">
|
||||||
|
<div class="canvas" id="canvas">
|
||||||
|
<div class="col-grid" id="colGrid"></div>
|
||||||
|
<div class="nowline d-none" id="nowLine"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted mt-2" id="boardFiltersInfo" style="font-size:.9rem;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
// --- Constants / API ---
|
||||||
|
const api = `${window.location.origin}/api/BookingsApi`;
|
||||||
|
const calendarScope = "calendar";
|
||||||
|
|
||||||
|
// --- Small helpers ---
|
||||||
|
const pad = n => String(n).padStart(2, "0");
|
||||||
|
function startOfMonth(d) { const x = new Date(d); x.setDate(1); x.setHours(0, 0, 0, 0); return x; }
|
||||||
|
function startOfWeek(d) { const x = new Date(d); const day = (x.getDay() + 7) % 7; x.setDate(x.getDate() - day); x.setHours(0, 0, 0, 0); return x; }
|
||||||
|
function addDays(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; }
|
||||||
|
function ymd(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }
|
||||||
|
function toUtcIso(d) { const off = d.getTimezoneOffset(); return new Date(d.getTime() - off * 60000).toISOString(); }
|
||||||
|
function fmtLocal(d) { const x = new Date(d); return `${pad(x.getHours())}:${pad(x.getMinutes())}`; }
|
||||||
|
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, m => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[m])); }
|
||||||
|
function alertMsg(msg) {
|
||||||
|
document.getElementById("alerts").innerHTML = `
|
||||||
|
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||||
|
${esc(String(msg))}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>`;
|
||||||
|
console.warn("[Bookings Calendar]", msg);
|
||||||
|
}
|
||||||
|
window.addEventListener("error", e => alertMsg(e.message));
|
||||||
|
window.addEventListener("unhandledrejection", e => alertMsg(e.reason?.message || e.reason));
|
||||||
|
|
||||||
|
// --- DOM refs ---
|
||||||
|
const lblMonth = document.getElementById("lblMonth");
|
||||||
|
const calGrid = document.getElementById("calGrid");
|
||||||
|
const dowRow = document.getElementById("dowRow");
|
||||||
|
const ddlRoom = document.getElementById("ddlRoom");
|
||||||
|
const ddlUser = document.getElementById("ddlUser");
|
||||||
|
|
||||||
|
// Day board elements
|
||||||
|
const boardModalEl = document.getElementById("dayBoardModal"); let boardModal;
|
||||||
|
const boardTitle = document.getElementById("boardTitle");
|
||||||
|
const boardFilters = document.getElementById("boardFiltersInfo");
|
||||||
|
const timeCol = document.getElementById("timeCol");
|
||||||
|
const colsHead = document.getElementById("colsHeader");
|
||||||
|
const colsScroll = document.getElementById("colsScroll");
|
||||||
|
const canvas = document.getElementById("canvas");
|
||||||
|
const colGrid = document.getElementById("colGrid");
|
||||||
|
const nowLine = document.getElementById("nowLine");
|
||||||
|
|
||||||
|
// DOW header (once)
|
||||||
|
if (!dowRow.dataset.done) {
|
||||||
|
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].forEach(d => {
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "dow";
|
||||||
|
div.textContent = d;
|
||||||
|
dowRow.appendChild(div);
|
||||||
|
});
|
||||||
|
dowRow.dataset.done = "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Lookups (rooms + users) ---
|
||||||
|
let userMap = new Map(), roomMap = new Map(), users = [], rooms = [];
|
||||||
|
async function loadLookups() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${api}?scope=${calendarScope}&lookups=1`);
|
||||||
|
if (!r.ok) throw new Error(`Lookups ${r.status} ${r.statusText}`);
|
||||||
|
const js = await r.json();
|
||||||
|
rooms = js.rooms ?? [];
|
||||||
|
users = js.users ?? [];
|
||||||
|
} catch (e) {
|
||||||
|
alertMsg(e.message);
|
||||||
|
rooms = [];
|
||||||
|
users = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill selects
|
||||||
|
ddlRoom.innerHTML = '<option value="">All rooms</option>';
|
||||||
|
rooms.forEach(r => {
|
||||||
|
const id = r.roomId;
|
||||||
|
const name = r.roomName ?? `Room ${id}`;
|
||||||
|
if (id == null) return;
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = name;
|
||||||
|
ddlRoom.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
ddlUser.innerHTML = '<option value="">All users</option>';
|
||||||
|
users.forEach(u => {
|
||||||
|
const id = u.Id ?? u.id;
|
||||||
|
const name = u.UserName ?? u.userName ?? "";
|
||||||
|
const email = u.Email ?? "";
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = email ? `${name} (${email})` : name;
|
||||||
|
ddlUser.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
userMap = new Map(
|
||||||
|
users.map(u => [
|
||||||
|
Number(u.Id ?? u.id),
|
||||||
|
(u.UserName ?? u.userName ?? "") + (u.Email ? ` (${u.Email})` : "")
|
||||||
|
])
|
||||||
|
);
|
||||||
|
roomMap = new Map(
|
||||||
|
rooms.map(r => {
|
||||||
|
const id = Number(r.roomId ?? r.RoomId);
|
||||||
|
const name = r.roomName ?? r.Name ?? r.RoomName ?? `Room ${id}`;
|
||||||
|
return [id, name];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data fetching for month grid ---
|
||||||
|
let curMonth = startOfMonth(new Date()), currentData = [];
|
||||||
|
async function fetchGridData(month) {
|
||||||
|
const gridStart = startOfWeek(startOfMonth(month));
|
||||||
|
const gridEnd = addDays(gridStart, 6 * 7 - 1);
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("scope", calendarScope); // <<— calendar scope (bypass RBAC for reads)
|
||||||
|
params.set("from", toUtcIso(gridStart));
|
||||||
|
params.set("to", toUtcIso(gridEnd));
|
||||||
|
params.set("pageSize", "10000");
|
||||||
|
if (ddlRoom.value) params.set("roomId", ddlRoom.value);
|
||||||
|
if (ddlUser.value) params.set("userId", ddlUser.value);
|
||||||
|
|
||||||
|
const r = await fetch(`${api}?${params.toString()}`);
|
||||||
|
if (!r.ok) throw new Error(`API ${r.status}: ${await r.text().catch(() => r.statusText)}`);
|
||||||
|
let data = await r.json();
|
||||||
|
|
||||||
|
// client-side refine: drop Cancelled and apply user filter (requested/target)
|
||||||
|
const userSel = ddlUser.value ? Number(ddlUser.value) : null;
|
||||||
|
data = data.filter(b => {
|
||||||
|
const status = (b.status ?? "").toString();
|
||||||
|
if (status === "Cancelled") return false;
|
||||||
|
if (userSel !== null) {
|
||||||
|
const rq = Number(b.requestedByUserId ?? b.UserId ?? b.userId ?? NaN);
|
||||||
|
const tg = Number(b.targetUserId ?? b.TargetUserId ?? NaN);
|
||||||
|
if (!(rq === userSel || tg === userSel)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { gridStart, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Month grid: skeleton & render ---
|
||||||
|
function renderSkeletonGrid(month) {
|
||||||
|
lblMonth.textContent = month.toLocaleString(undefined, { month: "long", year: "numeric" });
|
||||||
|
calGrid.innerHTML = "";
|
||||||
|
const gridStart = startOfWeek(startOfMonth(month));
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
const d = addDays(gridStart, i);
|
||||||
|
const cell = document.createElement("div");
|
||||||
|
cell.className = `cell free ${d.getMonth() === month.getMonth() ? "" : "other-month"}`;
|
||||||
|
cell.innerHTML = `
|
||||||
|
<div class="daynum">${d.getDate()}</div>
|
||||||
|
<div class="status-bar"><div style="width:0%"></div></div>
|
||||||
|
<div class="chip">Free</div>`;
|
||||||
|
cell.addEventListener("click", () => openDayBoard(d));
|
||||||
|
calGrid.appendChild(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDailyOccupancy(data, gridStart) {
|
||||||
|
const start = new Date(gridStart), end = addDays(start, 41);
|
||||||
|
const dayMap = new Map();
|
||||||
|
|
||||||
|
for (let d = new Date(start); d <= end; d = addDays(d, 1)) {
|
||||||
|
dayMap.set(ymd(d), { minutes: 0, items: [], date: new Date(d) });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const b of data) {
|
||||||
|
const s = new Date(b.startUtc), e = new Date(b.endUtc);
|
||||||
|
if (e < start || s > end) continue;
|
||||||
|
|
||||||
|
let cur = new Date(Math.max(s, start)); cur.setHours(0, 0, 0, 0);
|
||||||
|
while (cur <= end) {
|
||||||
|
const dayStart = new Date(cur), dayEnd = new Date(cur); dayEnd.setHours(23, 59, 59, 999);
|
||||||
|
const os = new Date(Math.max(s, dayStart)), oe = new Date(Math.min(e, dayEnd));
|
||||||
|
const mins = Math.max(0, Math.ceil((oe - os) / 60000));
|
||||||
|
if (mins > 0) {
|
||||||
|
const key = ymd(cur), entry = dayMap.get(key);
|
||||||
|
entry.minutes += Math.min(mins, 1440);
|
||||||
|
if (entry.items.length < 5) entry.items.push({ full: b, start: os, end: oe });
|
||||||
|
}
|
||||||
|
cur = addDays(cur, 1);
|
||||||
|
if (cur > e) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, entry] of dayMap) {
|
||||||
|
const pct = Math.max(0, Math.min(1, entry.minutes / 1440));
|
||||||
|
entry.pct = pct;
|
||||||
|
entry.className = pct === 0 ? "free" : (pct <= 0.6 ? "partial" : "busy");
|
||||||
|
}
|
||||||
|
return { start, dayMap };
|
||||||
|
}
|
||||||
|
|
||||||
|
let occCache = null;
|
||||||
|
function renderGrid(month) {
|
||||||
|
lblMonth.textContent = month.toLocaleString(undefined, { month: "long", year: "numeric" });
|
||||||
|
calGrid.innerHTML = "";
|
||||||
|
const start = occCache.start;
|
||||||
|
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
const d = addDays(start, i), key = ymd(d);
|
||||||
|
const entry = occCache.dayMap.get(key) || { pct: 0, className: "free", items: [], date: d };
|
||||||
|
|
||||||
|
const cell = document.createElement("div");
|
||||||
|
cell.className = `cell ${entry.className} ${d.getMonth() === month.getMonth() ? "" : "other-month"}`;
|
||||||
|
cell.title = entry.items.length ? `${entry.items.length} booking(s)` : "Free day";
|
||||||
|
cell.innerHTML = `
|
||||||
|
<div class="daynum">${d.getDate()}</div>
|
||||||
|
<div class="status-bar"><div style="width:${Math.round(entry.pct * 100)}%"></div></div>
|
||||||
|
<div class="chip">${entry.items.length ? `${entry.items.length} booking${entry.items.length > 1 ? "s" : ""}` : "Free"}</div>`;
|
||||||
|
cell.addEventListener("click", () => openDayBoard(d));
|
||||||
|
calGrid.appendChild(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Day board (rooms-only timeline) ---
|
||||||
|
const DAY_START_HOUR = 8, DAY_END_HOUR = 20, SLOT_MIN = 30, SLOT_PX = 32;
|
||||||
|
const TOTAL_INTERVALS = ((DAY_END_HOUR - DAY_START_HOUR) * 60) / SLOT_MIN; // 24
|
||||||
|
function getRoomId(b) { const v = [b.roomId, b.RoomId, b.roomID, b.room_id].find(x => x != null); const n = Number(v); return Number.isNaN(n) ? null : n; }
|
||||||
|
function getUserId(b) { const v = [b.requestedByUserId, b.UserId, b.userId, b.targetUserId, b.TargetUserId, b.bookedByUserId, b.BookedByUserId, b.approvedByUserId, b.ApprovedByUserId].find(x => x != null); const n = Number(v); return Number.isNaN(n) ? null : n; }
|
||||||
|
|
||||||
|
function openDayBoard(dayDate) {
|
||||||
|
boardTitle.textContent = `Schedule • ${dayDate.toLocaleDateString()}`;
|
||||||
|
boardFilters.textContent = `Filters: ${ddlRoom.options[ddlRoom.selectedIndex]?.text || "All rooms"}`;
|
||||||
|
|
||||||
|
// Left time labels
|
||||||
|
timeCol.innerHTML = "";
|
||||||
|
const timeInner = document.createElement("div");
|
||||||
|
timeInner.id = "timeInner";
|
||||||
|
timeCol.appendChild(timeInner);
|
||||||
|
for (let i = 0; i <= TOTAL_INTERVALS; i++) {
|
||||||
|
const mins = i * SLOT_MIN;
|
||||||
|
const hr = DAY_START_HOUR + Math.floor(mins / 60);
|
||||||
|
const mm = (mins % 60) === 0 ? "00" : "30";
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "time-slot";
|
||||||
|
row.textContent = `${String(hr).padStart(2, "0")}:${mm}`;
|
||||||
|
timeInner.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Columns = rooms (respect filter)
|
||||||
|
let columns = rooms
|
||||||
|
.filter(r => !ddlRoom.value || String(r.roomId ?? r.RoomId) === ddlRoom.value)
|
||||||
|
.map(r => {
|
||||||
|
const id = Number(r.roomId ?? r.RoomId);
|
||||||
|
const label = r.roomName ?? r.Name ?? r.RoomName ?? `Room ${id}`;
|
||||||
|
return { key: id, label };
|
||||||
|
});
|
||||||
|
if (columns.length === 0) columns = [{ key: Number.MIN_SAFE_INTEGER, label: "Room" }];
|
||||||
|
|
||||||
|
colsHead.innerHTML = "";
|
||||||
|
colsHead.style.gridTemplateColumns = `repeat(${columns.length},1fr)`;
|
||||||
|
columns.forEach(c => {
|
||||||
|
const h = document.createElement("div");
|
||||||
|
h.textContent = c.label;
|
||||||
|
colsHead.appendChild(h);
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.style.height = `${TOTAL_INTERVALS * SLOT_PX}px`;
|
||||||
|
|
||||||
|
// Build grid columns
|
||||||
|
colGrid.innerHTML = "";
|
||||||
|
colGrid.style.gridTemplateColumns = `repeat(${columns.length},1fr)`;
|
||||||
|
for (let i = 0; i < columns.length; i++) {
|
||||||
|
const g = document.createElement("div");
|
||||||
|
g.className = "gcol";
|
||||||
|
colGrid.appendChild(g);
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.style.position = "relative";
|
||||||
|
container.style.height = "100%";
|
||||||
|
g.appendChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal lines
|
||||||
|
[...canvas.querySelectorAll(".hline")].forEach(x => x.remove());
|
||||||
|
for (let i = 0; i <= TOTAL_INTERVALS; i++) {
|
||||||
|
const l = document.createElement("div");
|
||||||
|
l.className = "hline";
|
||||||
|
l.style.top = `${i * SLOT_PX}px`;
|
||||||
|
canvas.appendChild(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data rows for selected day
|
||||||
|
const dayStart = new Date(dayDate); dayStart.setHours(DAY_START_HOUR, 0, 0, 0);
|
||||||
|
const dayEnd = new Date(dayDate); dayEnd.setHours(DAY_END_HOUR, 0, 0, 0);
|
||||||
|
const rows = (currentData || []).filter(b => {
|
||||||
|
const s = new Date(b.startUtc), e = new Date(b.endUtc);
|
||||||
|
return e >= dayStart && s <= dayEnd;
|
||||||
|
});
|
||||||
|
|
||||||
|
function colIndexFor(b) {
|
||||||
|
const id = getRoomId(b);
|
||||||
|
const idx = columns.findIndex(c => c.key === id);
|
||||||
|
return idx === -1 ? 0 : idx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function posPx(s, e) {
|
||||||
|
const clipS = new Date(Math.max(s.getTime(), dayStart.getTime()));
|
||||||
|
const clipE = new Date(Math.min(e.getTime(), dayEnd.getTime()));
|
||||||
|
const minutesFromStart = (clipS.getTime() - dayStart.getTime()) / 60000; // 0..720
|
||||||
|
const dur = (clipE.getTime() - clipS.getTime()) / 60000; // 0..720
|
||||||
|
return {
|
||||||
|
topPx: (minutesFromStart / SLOT_MIN) * SLOT_PX,
|
||||||
|
hPx: (dur / SLOT_MIN) * SLOT_PX
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const b of rows) {
|
||||||
|
const s = new Date(b.startUtc), e = new Date(b.endUtc);
|
||||||
|
const { topPx, hPx } = posPx(s, e);
|
||||||
|
const idx = colIndexFor(b);
|
||||||
|
const container = colGrid.children[idx].firstChild;
|
||||||
|
|
||||||
|
const roomName = roomMap.get(getRoomId(b)) ?? (b.roomName ?? b.RoomName ?? "Room");
|
||||||
|
const whoName = userMap.get(getUserId(b) ?? -1) ?? (b.userName ?? b.UserName ?? "—");
|
||||||
|
const title = b.title ?? roomName;
|
||||||
|
const note = (b.note ?? b.description ?? b.Notes ?? "").toString();
|
||||||
|
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.className = "booking";
|
||||||
|
div.style.top = `${topPx}px`;
|
||||||
|
div.style.height = `${hPx}px`;
|
||||||
|
div.title = note ? `Notes: ${note}` : "";
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="t">${esc(title)}</div>
|
||||||
|
<div class="meta">${esc(fmtLocal(s))}–${esc(fmtLocal(e))}</div>
|
||||||
|
<div class="meta">By: ${esc(whoName)}</div>
|
||||||
|
${note ? `<div class="meta">Notes: ${esc(note)}</div>` : ""}`;
|
||||||
|
container.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now-line
|
||||||
|
const isToday = ymd(new Date()) === ymd(dayDate);
|
||||||
|
if (isToday) {
|
||||||
|
const now = new Date();
|
||||||
|
const mins = (now.getHours() * 60 + now.getMinutes()) - (DAY_START_HOUR * 60);
|
||||||
|
const px = Math.max(0, Math.min(TOTAL_INTERVALS * SLOT_PX, (mins / SLOT_MIN) * SLOT_PX));
|
||||||
|
nowLine.classList.remove("d-none");
|
||||||
|
nowLine.style.top = `${px}px`;
|
||||||
|
} else {
|
||||||
|
nowLine.classList.add("d-none");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll sync
|
||||||
|
colsScroll.removeEventListener("scroll", onColsScroll);
|
||||||
|
function onColsScroll() {
|
||||||
|
timeInner.style.transform = `translateY(-${colsScroll.scrollTop}px)`;
|
||||||
|
}
|
||||||
|
colsScroll.addEventListener("scroll", onColsScroll, { passive: true });
|
||||||
|
timeInner.style.transform = `translateY(-${colsScroll.scrollTop}px)`;
|
||||||
|
timeCol.scrollTop = colsScroll.scrollTop;
|
||||||
|
|
||||||
|
(boardModal ||= new bootstrap.Modal(boardModalEl)).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Navigation / state ---
|
||||||
|
document.getElementById("btnPrev").onclick = async () => { curMonth.setMonth(curMonth.getMonth() - 1); await redraw(); };
|
||||||
|
document.getElementById("btnNext").onclick = async () => { curMonth.setMonth(curMonth.getMonth() + 1); await redraw(); };
|
||||||
|
document.getElementById("btnToday").onclick = async () => { curMonth = startOfMonth(new Date()); await redraw(); };
|
||||||
|
ddlRoom.onchange = redraw;
|
||||||
|
ddlUser.onchange = redraw;
|
||||||
|
|
||||||
|
async function redraw() {
|
||||||
|
renderSkeletonGrid(curMonth);
|
||||||
|
try {
|
||||||
|
const { gridStart, data } = await fetchGridData(curMonth);
|
||||||
|
currentData = data;
|
||||||
|
occCache = computeDailyOccupancy(currentData, gridStart);
|
||||||
|
renderGrid(curMonth);
|
||||||
|
} catch (e) {
|
||||||
|
alertMsg(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Boot ---
|
||||||
|
(async function init() {
|
||||||
|
await loadLookups();
|
||||||
|
await redraw();
|
||||||
|
})();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
541
Areas/Bookings/Views/Bookings/Create.cshtml
Normal file
541
Areas/Bookings/Views/Bookings/Create.cshtml
Normal file
@ -0,0 +1,541 @@
|
|||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
|
||||||
|
@{
|
||||||
|
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.booking-wrap {
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 6px 24px rgba(0,0,0,.08);
|
||||||
|
border: 1px solid rgba(0,0,0,.06);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-header {
|
||||||
|
padding: 20px 24px;
|
||||||
|
background: linear-gradient(180deg, rgba(248,249,250,1) 0%, rgba(255,255,255,1) 100%);
|
||||||
|
border-bottom: 1px solid rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-body {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
color: #4a5568;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 12px 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: .85rem;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-card {
|
||||||
|
background: #fafafa;
|
||||||
|
border: 1px dashed rgba(0,0,0,.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-bar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 2;
|
||||||
|
background: rgba(255,255,255,.9);
|
||||||
|
backdrop-filter: saturate(180%) blur(6px);
|
||||||
|
border-top: 1px solid rgba(0,0,0,.06);
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-bar .btn {
|
||||||
|
min-width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-badge {
|
||||||
|
font-size: .7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #3730a3;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-left: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="booking-wrap container-fluid px-0">
|
||||||
|
<div class="booking-card">
|
||||||
|
<div class="booking-header d-flex align-items-center justify-content-between">
|
||||||
|
<h2 class="m-0">Create Booking</h2>
|
||||||
|
<div id="alerts" class="ms-3" style="min-height:38px;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="booking-body">
|
||||||
|
<form id="bookingForm" class="row g-3">
|
||||||
|
<input type="hidden" id="Id" />
|
||||||
|
|
||||||
|
<!-- DETAILS -->
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="section-title">Details</div>
|
||||||
|
<div class="form-card">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="Title" class="form-label">Title <span class="required-badge">Required</span></label>
|
||||||
|
<input id="Title" class="form-control" maxlength="150" placeholder="e.g., Weekly Project Sync" required />
|
||||||
|
<div class="hint mt-1">Max 150 characters.</div>
|
||||||
|
<div class="invalid-feedback">Title is required (max 150 chars).</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="RoomId" class="form-label">Room <span class="required-badge">Required</span></label>
|
||||||
|
<select id="RoomId" class="form-select" required>
|
||||||
|
<option value="">-- Select Room --</option>
|
||||||
|
</select>
|
||||||
|
<div class="hint mt-1">Only active rooms are shown.</div>
|
||||||
|
<div class="invalid-feedback">Please select a room.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SCHEDULE -->
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="section-title">Schedule</div>
|
||||||
|
<div class="form-card">
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="StartUtc" class="form-label">Start <span class="required-badge">Required</span></label>
|
||||||
|
<input id="StartUtc" type="datetime-local" class="form-control" step="1800" required />
|
||||||
|
<div class="hint mt-1">Local time; saved as UTC. 30-minute slots (e.g., 10:30, 11:00).</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="EndUtc" class="form-label">End <span class="required-badge">Required</span></label>
|
||||||
|
<input id="EndUtc" type="datetime-local" class="form-control" step="1800" required />
|
||||||
|
<div class="hint mt-1">Must be the same date as Start and after Start.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- REQUESTER -->
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="section-title">Requester</div>
|
||||||
|
<div class="form-card">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="RequestedByUserId" class="form-label">User <span class="required-badge">Required</span></label>
|
||||||
|
<select id="RequestedByUserId" class="form-select" required>
|
||||||
|
<option value="">-- Select User --</option>
|
||||||
|
</select>
|
||||||
|
<div class="hint mt-1">We’ll link the booking to this user.</div>
|
||||||
|
<div class="invalid-feedback">Please select a user.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="Note" class="form-label">Notes</label>
|
||||||
|
<textarea id="Note" class="form-control" maxlength="300" rows="3" placeholder="Optional (purpose, guests, equipment needs)"></textarea>
|
||||||
|
<div class="hint mt-1">Up to 300 characters. Included in Description server-side.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ACTIONS -->
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="actions-bar d-flex justify-content-end gap-2">
|
||||||
|
<a asp-area="Bookings" asp-controller="Bookings" asp-action="Index" class="btn btn-light border">Cancel</a>
|
||||||
|
<button type="submit" id="submitBtn" class="btn btn-primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
// --- Constants ---
|
||||||
|
const DAY_START_HOUR = 8; // 08:00
|
||||||
|
const DAY_END_HOUR = 20; // 20:00
|
||||||
|
const api = `${window.location.origin}/api/BookingsApi`;
|
||||||
|
|
||||||
|
// ---------- UTC <-> datetime-local helpers ----------
|
||||||
|
function toLocalInputValue(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
const hasTz = /(?:Z|[+\-]\d{2}:\d{2})$/i.test(String(iso));
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d)) return "";
|
||||||
|
const ms = hasTz
|
||||||
|
? d.getTime() - d.getTimezoneOffset() * 60000
|
||||||
|
: d.getTime();
|
||||||
|
return new Date(ms).toISOString().slice(0, 16); // "YYYY-MM-DDTHH:mm"
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromLocalInputValue(localValue) {
|
||||||
|
if (!localValue) return null;
|
||||||
|
return new Date(localValue).toISOString(); // local -> UTC Z
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- UI helpers ----------
|
||||||
|
function showAlert(type, msg) {
|
||||||
|
document.getElementById("alerts").innerHTML = `
|
||||||
|
<div class="alert alert-${type} alert-dismissible fade show mb-0" role="alert">
|
||||||
|
${msg}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueryId() {
|
||||||
|
const qs = new URLSearchParams(window.location.search);
|
||||||
|
const qid = parseInt(qs.get("id") || "", 10);
|
||||||
|
return Number.isInteger(qid) && qid > 0 ? qid : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(n) {
|
||||||
|
return String(n).padStart(2, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLocalYmdHm(d) {
|
||||||
|
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(
|
||||||
|
d.getDate()
|
||||||
|
)}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Time helpers ----------
|
||||||
|
function snapTo30(localValue) {
|
||||||
|
if (!localValue) return localValue;
|
||||||
|
const d = new Date(localValue);
|
||||||
|
if (isNaN(d)) return localValue;
|
||||||
|
d.setSeconds(0, 0);
|
||||||
|
const m = d.getMinutes();
|
||||||
|
let snappedMin;
|
||||||
|
if (m < 15) snappedMin = 0;
|
||||||
|
else if (m < 45) snappedMin = 30;
|
||||||
|
else {
|
||||||
|
d.setHours(d.getHours() + 1);
|
||||||
|
snappedMin = 0;
|
||||||
|
}
|
||||||
|
d.setMinutes(snappedMin);
|
||||||
|
return formatLocalYmdHm(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceEndSameDay() {
|
||||||
|
const startEl = document.getElementById("StartUtc");
|
||||||
|
const endEl = document.getElementById("EndUtc");
|
||||||
|
const sv = startEl.value,
|
||||||
|
ev = endEl.value;
|
||||||
|
if (!sv || !ev) return;
|
||||||
|
|
||||||
|
const sd = new Date(sv);
|
||||||
|
const ed = new Date(ev);
|
||||||
|
if (isNaN(sd) || isNaN(ed)) return;
|
||||||
|
|
||||||
|
const datesDiffer =
|
||||||
|
sd.getFullYear() !== ed.getFullYear() ||
|
||||||
|
sd.getMonth() !== ed.getMonth() ||
|
||||||
|
sd.getDate() !== ed.getDate();
|
||||||
|
|
||||||
|
if (datesDiffer) {
|
||||||
|
ed.setFullYear(sd.getFullYear(), sd.getMonth(), sd.getDate());
|
||||||
|
endEl.value = formatLocalYmdHm(ed);
|
||||||
|
showAlert("warning", "End date was adjusted to the same day as Start.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function applyEndMinMaxForStartDay() {
|
||||||
|
const startEl = document.getElementById("StartUtc");
|
||||||
|
const endEl = document.getElementById("EndUtc");
|
||||||
|
const sv = startEl.value;
|
||||||
|
|
||||||
|
// compute local "now" rounded up to next :00/:30
|
||||||
|
const nowLocalCeil = ceilToNext30Date(new Date());
|
||||||
|
|
||||||
|
if (!sv) {
|
||||||
|
// if empty, at least prevent any past time today
|
||||||
|
startEl.min = formatLocalYmdHm(nowLocalCeil);
|
||||||
|
endEl.min = formatLocalYmdHm(nowLocalCeil);
|
||||||
|
startEl.removeAttribute("max");
|
||||||
|
endEl.removeAttribute("max");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sd = new Date(sv);
|
||||||
|
if (isNaN(sd)) {
|
||||||
|
startEl.min = formatLocalYmdHm(nowLocalCeil);
|
||||||
|
endEl.min = formatLocalYmdHm(nowLocalCeil);
|
||||||
|
startEl.removeAttribute("max");
|
||||||
|
endEl.removeAttribute("max");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// the fixed day window
|
||||||
|
const dayStart = new Date(sd); dayStart.setHours(DAY_START_HOUR, 0, 0, 0);
|
||||||
|
const dayEnd = new Date(sd); dayEnd.setHours(DAY_END_HOUR, 0, 0, 0);
|
||||||
|
|
||||||
|
// if the chosen start day is today, min is max(dayStart, nowLocalCeil); else min is dayStart
|
||||||
|
const today = new Date();
|
||||||
|
const isSameDay =
|
||||||
|
sd.getFullYear() === today.getFullYear() &&
|
||||||
|
sd.getMonth() === today.getMonth() &&
|
||||||
|
sd.getDate() === today.getDate();
|
||||||
|
|
||||||
|
const startMin = isSameDay && nowLocalCeil > dayStart ? nowLocalCeil : dayStart;
|
||||||
|
const latestStart = new Date(dayEnd.getTime() - 30 * 60000); // 19:30
|
||||||
|
|
||||||
|
// apply to Start picker
|
||||||
|
startEl.min = formatLocalYmdHm(startMin);
|
||||||
|
startEl.max = formatLocalYmdHm(latestStart);
|
||||||
|
|
||||||
|
// clamp Start into allowed window
|
||||||
|
if (new Date(startEl.value) < startMin) startEl.value = formatLocalYmdHm(startMin);
|
||||||
|
if (new Date(startEl.value) > latestStart) startEl.value = formatLocalYmdHm(latestStart);
|
||||||
|
|
||||||
|
// End is tied to (snapped) Start and capped by dayEnd
|
||||||
|
const snappedStartStr = snapTo30(startEl.value);
|
||||||
|
const snappedStart = new Date(snappedStartStr);
|
||||||
|
|
||||||
|
endEl.min = formatLocalYmdHm(snappedStart);
|
||||||
|
endEl.max = formatLocalYmdHm(dayEnd);
|
||||||
|
|
||||||
|
// clamp End
|
||||||
|
if (endEl.value) {
|
||||||
|
const ed = new Date(endEl.value);
|
||||||
|
if (ed < snappedStart) endEl.value = snappedStartStr;
|
||||||
|
else if (ed > dayEnd) endEl.value = formatLocalYmdHm(dayEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
enforceEndSameDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function ceilToNext30Date(d) {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setSeconds(0, 0);
|
||||||
|
const m = x.getMinutes();
|
||||||
|
if (m === 0 || m === 30) return x;
|
||||||
|
if (m < 30) { x.setMinutes(30); return x; }
|
||||||
|
x.setHours(x.getHours() + 1); x.setMinutes(0); return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTimes() {
|
||||||
|
const startEl = document.getElementById("StartUtc");
|
||||||
|
const endEl = document.getElementById("EndUtc");
|
||||||
|
|
||||||
|
if (startEl.value) startEl.value = snapTo30(startEl.value);
|
||||||
|
if (endEl.value) endEl.value = snapTo30(endEl.value);
|
||||||
|
|
||||||
|
applyEndMinMaxForStartDay();
|
||||||
|
|
||||||
|
if (startEl.value && endEl.value) {
|
||||||
|
const sd = new Date(startEl.value);
|
||||||
|
const ed = new Date(endEl.value);
|
||||||
|
if (ed < sd) {
|
||||||
|
const newEnd = new Date(sd);
|
||||||
|
newEnd.setMinutes(sd.getMinutes() + 30);
|
||||||
|
endEl.value = formatLocalYmdHm(newEnd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Lookups ----------
|
||||||
|
async function loadLookups(selectedRoomId, selectedUserId) {
|
||||||
|
const res = await fetch(`${api}?lookups=1`);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const { rooms = [], users = [] } = await res.json();
|
||||||
|
|
||||||
|
const pick = (obj, ...keys) => {
|
||||||
|
for (const k of keys)
|
||||||
|
if (obj[k] !== undefined && obj[k] !== null) return obj[k];
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rooms
|
||||||
|
const roomDdl = document.getElementById("RoomId");
|
||||||
|
roomDdl.innerHTML = '<option value="">-- Select Room --</option>';
|
||||||
|
rooms.forEach(r => {
|
||||||
|
const id = pick(r, "RoomId", "roomId");
|
||||||
|
if (id == null) return;
|
||||||
|
|
||||||
|
const name =
|
||||||
|
(pick(r, "Name", "name", "RoomName", "roomName") ?? `Room ${id}`)
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
const loc = pick(r, "LocationCode", "locationCode");
|
||||||
|
const cap = pick(r, "Capacity", "capacity");
|
||||||
|
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = `${name}${loc ? ` @@ ${loc}` : ""}${cap ? ` — cap ${cap}` : ""
|
||||||
|
}`;
|
||||||
|
if (selectedRoomId && Number(selectedRoomId) === Number(id))
|
||||||
|
opt.selected = true;
|
||||||
|
roomDdl.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Users
|
||||||
|
const userDdl = document.getElementById("RequestedByUserId");
|
||||||
|
userDdl.innerHTML = '<option value="">-- Select User --</option>';
|
||||||
|
users.forEach(u => {
|
||||||
|
const id = pick(u, "Id", "id");
|
||||||
|
if (id == null) return;
|
||||||
|
|
||||||
|
const name = pick(u, "UserName", "userName") ?? "";
|
||||||
|
const email = pick(u, "Email", "email") ?? "";
|
||||||
|
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = email ? `${name} (${email})` : name;
|
||||||
|
if (selectedUserId && Number(selectedUserId) === Number(id))
|
||||||
|
opt.selected = true;
|
||||||
|
userDdl.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Form submit ----------
|
||||||
|
document.getElementById("bookingForm").addEventListener("submit", async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
normalizeTimes();
|
||||||
|
|
||||||
|
const id = document.getElementById("Id").value;
|
||||||
|
const title = document.getElementById("Title").value.trim();
|
||||||
|
const roomId = Number(document.getElementById("RoomId").value);
|
||||||
|
const startLocal = document.getElementById("StartUtc").value;
|
||||||
|
const endLocal = document.getElementById("EndUtc").value;
|
||||||
|
const requestedByUserId = Number(
|
||||||
|
document.getElementById("RequestedByUserId").value
|
||||||
|
);
|
||||||
|
const note = (document.getElementById("Note").value || "").trim();
|
||||||
|
|
||||||
|
if (!title || !roomId || !startLocal || !endLocal || !requestedByUserId) {
|
||||||
|
showAlert("danger", "Please fill all required fields.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sd = new Date(startLocal),
|
||||||
|
ed = new Date(endLocal);
|
||||||
|
const sameDay =
|
||||||
|
sd.getFullYear() === ed.getFullYear() &&
|
||||||
|
sd.getMonth() === ed.getMonth() &&
|
||||||
|
sd.getDate() === ed.getDate();
|
||||||
|
if (!sameDay) {
|
||||||
|
showAlert("danger", "End must be on the same date as Start.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startMinutes = sd.getHours() * 60 + sd.getMinutes();
|
||||||
|
const endMinutes = ed.getHours() * 60 + ed.getMinutes();
|
||||||
|
const latestStartMinutes = DAY_END_HOUR * 60 - 30;
|
||||||
|
|
||||||
|
if (startMinutes < DAY_START_HOUR * 60 || startMinutes > latestStartMinutes) {
|
||||||
|
showAlert("danger", "Start must be between 08:00 and 19:30.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (endMinutes > DAY_END_HOUR * 60) {
|
||||||
|
showAlert("danger", "End must be no later than 20:00.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startUtc = fromLocalInputValue(startLocal);
|
||||||
|
const endUtc = fromLocalInputValue(endLocal);
|
||||||
|
if (new Date(endUtc) <= new Date(startUtc)) {
|
||||||
|
showAlert("danger", "End time must be after Start time.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (id) {
|
||||||
|
const payload = {
|
||||||
|
RoomId: roomId,
|
||||||
|
Title: title,
|
||||||
|
StartUtc: startUtc,
|
||||||
|
EndUtc: endUtc,
|
||||||
|
Note: note || null
|
||||||
|
};
|
||||||
|
res = await fetch(`${api}?id=${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const payload = {
|
||||||
|
RoomId: roomId,
|
||||||
|
RequestedByUserId: requestedByUserId,
|
||||||
|
Title: title,
|
||||||
|
StartUtc: startUtc,
|
||||||
|
EndUtc: endUtc,
|
||||||
|
Note: note || null,
|
||||||
|
Description: note || null
|
||||||
|
};
|
||||||
|
res = await fetch(`${api}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text();
|
||||||
|
showAlert("danger", (id ? "Update" : "Create") + " failed: " + t);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.href = '@Url.Action("Index", "Bookings", new { area = "Bookings" })';
|
||||||
|
} catch (err) {
|
||||||
|
showAlert("danger", (id ? "Update" : "Create") + " failed: " + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Boot ----------
|
||||||
|
document.addEventListener("DOMContentLoaded", async () => {
|
||||||
|
const id = getQueryId();
|
||||||
|
if (id) {
|
||||||
|
const res = await fetch(`${api}?id=${encodeURIComponent(id)}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
showAlert("danger", await res.text());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const b = await res.json();
|
||||||
|
|
||||||
|
document.getElementById("Id").value = b.id ?? b.Id ?? b.bookingId;
|
||||||
|
document.getElementById("Title").value = b.title ?? "";
|
||||||
|
document.getElementById("StartUtc").value = snapTo30(
|
||||||
|
toLocalInputValue(b.startUtc)
|
||||||
|
);
|
||||||
|
document.getElementById("EndUtc").value = snapTo30(
|
||||||
|
toLocalInputValue(b.endUtc)
|
||||||
|
);
|
||||||
|
document.getElementById("Note").value = b.note ?? "";
|
||||||
|
|
||||||
|
await loadLookups(b.roomId, b.requestedByUserId);
|
||||||
|
const nowLocalCeil = ceilToNext30Date(new Date());
|
||||||
|
document.getElementById("StartUtc").min = formatLocalYmdHm(nowLocalCeil);
|
||||||
|
document.getElementById("EndUtc").min = formatLocalYmdHm(nowLocalCeil);
|
||||||
|
|
||||||
|
applyEndMinMaxForStartDay();
|
||||||
|
normalizeTimes();
|
||||||
|
|
||||||
|
document.getElementById("submitBtn").textContent = "Update";
|
||||||
|
} else {
|
||||||
|
await loadLookups();
|
||||||
|
const nowLocalCeil = ceilToNext30Date(new Date());
|
||||||
|
document.getElementById("StartUtc").min = formatLocalYmdHm(nowLocalCeil);
|
||||||
|
document.getElementById("EndUtc").min = formatLocalYmdHm(nowLocalCeil);
|
||||||
|
applyEndMinMaxForStartDay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
}
|
||||||
861
Areas/Bookings/Views/Bookings/Index.cshtml
Normal file
861
Areas/Bookings/Views/Bookings/Index.cshtml
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pagination-info {
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,.1);
|
||||||
|
padding: 25px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th, .table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-green {
|
||||||
|
background-color: #d9ead3 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-blue {
|
||||||
|
background-color: #cfe2f3 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-orange {
|
||||||
|
background-color: #fce5cd !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn, input.form-control, select.form-control {
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filters card */
|
||||||
|
.filters-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(0,0,0,.06);
|
||||||
|
box-shadow: 0 6px 18px rgba(0,0,0,.06);
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12,1fr);
|
||||||
|
gap: 12px 16px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-field {
|
||||||
|
grid-column: span 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-field .form-control, .filters-field .form-select {
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-actions {
|
||||||
|
grid-column: 1/-1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters-actions .btn {
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (max-width:1200px) {
|
||||||
|
.filters-field {
|
||||||
|
grid-column: span 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (max-width:768px) {
|
||||||
|
.filters-field {
|
||||||
|
grid-column: span 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (max-width:576px) {
|
||||||
|
.filters-field {
|
||||||
|
grid-column: span 12;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fixed layout so <colgroup> widths are honored */
|
||||||
|
.table-fixed {
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column widths via <colgroup> classes */
|
||||||
|
.col-title {
|
||||||
|
width: 28ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-notes {
|
||||||
|
width: 10ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-room {
|
||||||
|
width: 12ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-date {
|
||||||
|
width: 14ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-time {
|
||||||
|
width: 16ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-user {
|
||||||
|
width: 14ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-actions {
|
||||||
|
width: 150px;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Long text cells */
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.td-title, .td-notes {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Compact actions */
|
||||||
|
.actions-col {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Notes overlay ===== */
|
||||||
|
.note-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,.35);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1080;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,.2);
|
||||||
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 18px 18px 14px;
|
||||||
|
border: 1px solid rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card h6 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card .note-body {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
--size: 34px;
|
||||||
|
width: var(--size);
|
||||||
|
height: var(--size);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(0,0,0,.1);
|
||||||
|
background: #fff;
|
||||||
|
margin: 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:disabled {
|
||||||
|
opacity: .5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||||
|
<script>
|
||||||
|
window.__ctx = { isManager: @(isMgr ? "true" : "false"), meId: @meId };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2 class="mb-0"></h2>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button id="btnToggleFilters" type="button" class="btn btn-outline-secondary">Filters</button>
|
||||||
|
<a asp-area="Bookings" asp-controller="Bookings" asp-action="Create" class="btn btn-primary">Create New</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="alerts"></div>
|
||||||
|
|
||||||
|
<!-- Status Tabs -->
|
||||||
|
<ul class="nav nav-tabs mb-3">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" :class="{ active: activeStatus === 'Pending' }" href="#" @@click.prevent="setStatus('Pending')">
|
||||||
|
Pending <span class="badge bg-warning text-dark">{{ statusCounts.Pending }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" :class="{ active: activeStatus === 'Approved' }" href="#" @@click.prevent="setStatus('Approved')">
|
||||||
|
Approved <span class="badge bg-success">{{ statusCounts.Approved }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" :class="{ active: activeStatus === 'Rejected' }" href="#" @@click.prevent="setStatus('Rejected')">
|
||||||
|
Rejected <span class="badge bg-dark">{{ statusCounts.Rejected }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" :class="{ active: activeStatus === 'Cancelled' }" href="#" @@click.prevent="setStatus('Cancelled')">
|
||||||
|
Cancelled <span class="badge bg-danger">{{ statusCounts.Cancelled }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="table-responsive table-container">
|
||||||
|
<!-- Filters Panel -->
|
||||||
|
<div id="filtersCard" class="filters-card">
|
||||||
|
<div class="filters-grid">
|
||||||
|
<div class="filters-field">
|
||||||
|
<label for="fltFrom">From date</label>
|
||||||
|
<input id="fltFrom" type="date" class="form-control" placeholder="dd/mm/yyyy" />
|
||||||
|
</div>
|
||||||
|
<div class="filters-field">
|
||||||
|
<label for="fltTo">To date</label>
|
||||||
|
<input id="fltTo" type="date" class="form-control" placeholder="dd/mm/yyyy" />
|
||||||
|
</div>
|
||||||
|
<div class="filters-field" id="userFilterField" @(isMgr ? "" : "style='display:none'")>
|
||||||
|
<label for="fltUser">User</label>
|
||||||
|
<select id="fltUser" class="form-select">
|
||||||
|
<option value="">All users</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="filters-actions">
|
||||||
|
<button id="btnApply" type="button" class="btn btn-primary">Apply</button>
|
||||||
|
<button id="btnClear" type="button" class="btn btn-outline-secondary">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table table-bordered table-striped align-middle table-fixed" id="bookingsTable">
|
||||||
|
<colgroup>
|
||||||
|
<col class="col-title">
|
||||||
|
<col class="col-notes">
|
||||||
|
<col class="col-room">
|
||||||
|
<col class="col-date"> <!-- new Date column -->
|
||||||
|
<col class="col-time"> <!-- new Time column -->
|
||||||
|
@if (isMgr)
|
||||||
|
{
|
||||||
|
<col class="col-user">
|
||||||
|
}
|
||||||
|
<col class="col-actions">
|
||||||
|
</colgroup>
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="header-green">Title</th>
|
||||||
|
<th class="header-green">Notes</th>
|
||||||
|
<th class="header-blue">Room</th>
|
||||||
|
<th class="header-blue">Date</th>
|
||||||
|
<th class="header-blue">Time</th>
|
||||||
|
@if (isMgr)
|
||||||
|
{
|
||||||
|
<th class="header-blue">User</th>
|
||||||
|
}
|
||||||
|
<th class="header-orange">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td colspan="@(isMgr ? 7 : 6)" class="text-center">Loading…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Pager -->
|
||||||
|
<div id="pagerBar" class="d-flex justify-content-between align-items-center mt-2">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<button id="btnPrev" class="btn btn-outline-secondary btn-sm me-2" disabled>« Prev</button>
|
||||||
|
<button id="btnNext" class="btn btn-outline-secondary btn-sm" disabled>Next »</button>
|
||||||
|
</div>
|
||||||
|
<div class="pagination-info small">
|
||||||
|
<span id="pageInfo">Page 1 of 1</span>
|
||||||
|
<span id="rangeInfo" class="ms-2">(Showing 0–0 of 0)</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<label for="selPageSize" class="me-2 small mb-0">Items per page</label>
|
||||||
|
<select id="selPageSize" class="form-select form-select-sm" style="width:auto">
|
||||||
|
<option value="5">5</option>
|
||||||
|
<option value="10" selected>10</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
<option value="50">50</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes overlay root -->
|
||||||
|
<div id="noteOverlay" class="note-overlay" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
const app = Vue.createApp({
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
ctx: window.__ctx || { isManager: false, meId: 0 },
|
||||||
|
api: `${window.location.origin}/api/BookingsApi`,
|
||||||
|
|
||||||
|
users: [], rooms: [],
|
||||||
|
userMap: new Map(), roomMap: new Map(),
|
||||||
|
|
||||||
|
showFilters: false,
|
||||||
|
fltFrom: "", fltTo: "", fltUser: "",
|
||||||
|
|
||||||
|
activeStatus: "Pending",
|
||||||
|
statuses: ["Pending", "Approved", "Rejected", "Cancelled"],
|
||||||
|
|
||||||
|
rows: [],
|
||||||
|
isLoading: false,
|
||||||
|
alert: { type: "", msg: "" },
|
||||||
|
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
|
||||||
|
notesById: new Map()
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
showFilters() {
|
||||||
|
const card = document.getElementById("filtersCard");
|
||||||
|
if (card) card.style.display = this.showFilters ? "block" : "none";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
queryParams() {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
p.set("pageSize", "500");
|
||||||
|
|
||||||
|
const fromIso = this.localDateToUtcIsoStart(this.fltFrom);
|
||||||
|
const toIso = this.localDateToUtcIsoEnd(this.fltTo);
|
||||||
|
if (fromIso) p.set("from", fromIso);
|
||||||
|
if (toIso) p.set("to", toIso);
|
||||||
|
|
||||||
|
if (this.ctx.isManager && this.fltUser) p.set("userId", String(this.fltUser));
|
||||||
|
|
||||||
|
return p.toString();
|
||||||
|
},
|
||||||
|
|
||||||
|
statusCounts() {
|
||||||
|
const counts = { Pending: 0, Approved: 0, Rejected: 0, Cancelled: 0 };
|
||||||
|
for (const r of this.rows) {
|
||||||
|
const s = (r.status ?? r.Status ?? "").toString();
|
||||||
|
if (counts[s] != null) counts[s]++;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
},
|
||||||
|
|
||||||
|
filteredRows() {
|
||||||
|
const list = this.rows.filter(r => {
|
||||||
|
const st = (r.status ?? r.Status ?? "").toString();
|
||||||
|
if (st !== this.activeStatus) return false;
|
||||||
|
if (this.ctx.isManager && this.fltUser) {
|
||||||
|
const uid = Number(r.requestedByUserId ?? r.RequestedByUserId ?? r.userId ?? 0);
|
||||||
|
if (String(uid) !== String(this.fltUser)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return list.slice().sort((a, b) => {
|
||||||
|
const aC = this.getTimeNum(a.createdUtc ?? a.CreatedUtc);
|
||||||
|
const bC = this.getTimeNum(b.createdUtc ?? b.CreatedUtc);
|
||||||
|
if (aC !== bC) return (bC ?? -Infinity) - (aC ?? -Infinity);
|
||||||
|
|
||||||
|
const aS = this.getTimeNum(a.startUtc ?? a.StartUtc);
|
||||||
|
const bS = this.getTimeNum(b.startUtc ?? b.StartUtc);
|
||||||
|
if (aS !== bS) return (bS ?? -Infinity) - (aS ?? -Infinity);
|
||||||
|
|
||||||
|
const aId = this.getNum(a.id ?? a.Id ?? a.bookingId ?? a.BookingId);
|
||||||
|
const bId = this.getNum(b.id ?? b.Id ?? b.bookingId ?? b.BookingId);
|
||||||
|
return (bId ?? -Infinity) - (aId ?? -Infinity);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
totalPages() { return Math.max(1, Math.ceil(this.filteredRows.length / this.pageSize)); },
|
||||||
|
|
||||||
|
pagedRows() {
|
||||||
|
const start = (this.currentPage - 1) * this.pageSize;
|
||||||
|
const end = start + this.pageSize;
|
||||||
|
return this.filteredRows.slice(start, end);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
// ===== tabs & alerts =====
|
||||||
|
setStatus(s) {
|
||||||
|
if (this.activeStatus !== s) {
|
||||||
|
this.activeStatus = s;
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.renderTable();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setAlert(type, msg) {
|
||||||
|
this.alert = { type, msg };
|
||||||
|
const wrap = document.getElementById("alerts");
|
||||||
|
if (!wrap) return;
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||||
|
${this.escapeHtml(msg)}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
escapeHtml(s) {
|
||||||
|
return String(s ?? "").replace(/[&<>\"']/g, m => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||||
|
}[m]));
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== time & formatting helpers =====
|
||||||
|
getTimeNum(v) { if (!v) return null; const t = new Date(v).getTime(); return Number.isNaN(t) ? null : t; },
|
||||||
|
getNum(v) { const n = Number(v); return Number.isNaN(n) ? null : n; },
|
||||||
|
|
||||||
|
/* e.g., 7/9/2025 (no leading zeros) */
|
||||||
|
formatDateDMY(s) {
|
||||||
|
if (!s) return "";
|
||||||
|
const d = new Date(s); if (isNaN(d)) return this.escapeHtml(s);
|
||||||
|
return `${d.getDate()}/${d.getMonth() + 1}/${d.getFullYear()}`;
|
||||||
|
},
|
||||||
|
/* e.g., 08:05 (zero-padded) */
|
||||||
|
formatTimeHM(s) {
|
||||||
|
if (!s) return "";
|
||||||
|
const d = new Date(s); if (isNaN(d)) return "";
|
||||||
|
const pad = n => String(n).padStart(2, "0");
|
||||||
|
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
localDateToUtcIsoStart(dateStr) {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
const d = new Date(dateStr + "T00:00");
|
||||||
|
const utc = new Date(d.getTime() - d.getTimezoneOffset() * 60000);
|
||||||
|
return utc.toISOString();
|
||||||
|
},
|
||||||
|
localDateToUtcIsoEnd(dateStr) {
|
||||||
|
if (!dateStr) return null;
|
||||||
|
const d = new Date(dateStr + "T23:59:59.999");
|
||||||
|
const utc = new Date(d.getTime() - d.getTimezoneOffset() * 60000);
|
||||||
|
return utc.toISOString();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== lookups & data =====
|
||||||
|
async loadLookups() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.api}?lookups=1`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const { rooms = [], users = [] } = await res.json();
|
||||||
|
|
||||||
|
this.users = users; this.rooms = rooms;
|
||||||
|
|
||||||
|
const sel = document.getElementById("fltUser");
|
||||||
|
if (sel) {
|
||||||
|
const prev = sel.value ?? "";
|
||||||
|
sel.innerHTML = '<option value="">All users</option>';
|
||||||
|
users.forEach(u => {
|
||||||
|
const id = Number(u.Id ?? u.id); if (!id) return;
|
||||||
|
const name = u.UserName ?? u.userName ?? "";
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = String(id); opt.textContent = name;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (prev && [...sel.options].some(o => o.value === prev)) { sel.value = prev; this.fltUser = prev; }
|
||||||
|
}
|
||||||
|
|
||||||
|
this.userMap = new Map(users.map(u => [Number(u.Id ?? u.id), (u.UserName ?? u.userName ?? "")]));
|
||||||
|
this.roomMap = new Map(rooms.map(r => {
|
||||||
|
const id = Number(r.roomId ?? r.RoomId);
|
||||||
|
const name = r.roomName ?? r.Name ?? r.RoomName ?? `Room ${id}`;
|
||||||
|
return [id, name];
|
||||||
|
}));
|
||||||
|
} catch { }
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadList() {
|
||||||
|
const $tbody = document.querySelector("#bookingsTable tbody");
|
||||||
|
const cols = this.ctx.isManager ? 7 : 6;
|
||||||
|
if ($tbody) $tbody.innerHTML = `<tr><td colspan="${cols}" class="text-center">Loading…</td></tr>`;
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${this.api}?${this.queryParams}`);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const data = await res.json();
|
||||||
|
this.rows = Array.isArray(data) ? data : [];
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.renderTable();
|
||||||
|
} catch (err) {
|
||||||
|
if ($tbody) $tbody.innerHTML = `<tr><td colspan="${cols}" class="text-danger text-center">Failed to load: ${this.escapeHtml(err.message)}</td></tr>`;
|
||||||
|
this.setAlert("danger", err.message || "Failed to load.");
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== table render =====
|
||||||
|
renderTable() {
|
||||||
|
const $tbody = document.querySelector("#bookingsTable tbody");
|
||||||
|
if (!$tbody) return;
|
||||||
|
|
||||||
|
const visible = this.pedRowsSafe();
|
||||||
|
const cols = this.ctx.isManager ? 7 : 6;
|
||||||
|
|
||||||
|
if (!visible.length) {
|
||||||
|
$tbody.innerHTML = `<tr><td colspan="${cols}" class="text-center">No bookings found.</td></tr>`;
|
||||||
|
this.updatePager();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tbody.innerHTML = "";
|
||||||
|
const showModeration = this.ctx.isManager && this.activeStatus === "Pending";
|
||||||
|
this.notesById = new Map();
|
||||||
|
|
||||||
|
for (const b of visible) {
|
||||||
|
const id = b.id ?? b.Id ?? b.bookingId ?? b.BookingId;
|
||||||
|
const title = b.title ?? "";
|
||||||
|
const note = (b.note ?? b.description ?? "").toString();
|
||||||
|
const hasNote = note.trim().length > 0;
|
||||||
|
if (id != null) this.notesById.set(String(id), note);
|
||||||
|
|
||||||
|
const start = b.startUtc ?? b.StartUtc;
|
||||||
|
const end = b.endUtc ?? b.EndUtc;
|
||||||
|
|
||||||
|
const dateDisp = this.formatDateDMY(start);
|
||||||
|
const timeStart = this.formatTimeHM(start);
|
||||||
|
const timeEnd = this.formatTimeHM(end);
|
||||||
|
const timeDisp = (timeStart || timeEnd) ? `${timeStart || "—"} - ${timeEnd || "—"}` : "—";
|
||||||
|
|
||||||
|
const ridRaw = (b.roomId ?? b.RoomId ?? null);
|
||||||
|
const roomId = ridRaw == null ? null : Number(ridRaw);
|
||||||
|
const roomDisp = (roomId && !Number.isNaN(roomId))
|
||||||
|
? (this.roomMap.get(roomId) ?? `Room #${roomId}`)
|
||||||
|
: (b.roomName ?? "(unknown)");
|
||||||
|
|
||||||
|
const uidRaw = (b.requestedByUserId ?? b.RequestedByUserId ?? null);
|
||||||
|
const uid = uidRaw == null ? null : Number(uidRaw);
|
||||||
|
const userDisp = (uid && !Number.isNaN(uid))
|
||||||
|
? (this.userMap.get(uid) ?? `User #${uid}`)
|
||||||
|
: (b.userName ?? "(unknown)");
|
||||||
|
|
||||||
|
const status = (b.status ?? b.Status ?? "").toString();
|
||||||
|
const isCancelled = status === "Cancelled";
|
||||||
|
const isApproved = status === "Approved";
|
||||||
|
const isRejected = status === "Rejected";
|
||||||
|
|
||||||
|
const canEdit = !(isApproved || isRejected || isCancelled);
|
||||||
|
const canCancel = !isRejected;
|
||||||
|
|
||||||
|
const cancelLabel = isCancelled ? "Un-cancel" : "Cancel";
|
||||||
|
const approveDisabled = (isApproved || isCancelled) ? "disabled" : "";
|
||||||
|
const rejectDisabled = (isRejected || isCancelled) ? "disabled" : "";
|
||||||
|
|
||||||
|
const managerMenu = showModeration ? `
|
||||||
|
<li><button type="button" class="dropdown-item" data-action="approve" data-id="${id}" ${approveDisabled}>Approve</button></li>
|
||||||
|
<li><button type="button" class="dropdown-item" data-action="reject" data-id="${id}" ${rejectDisabled}>Reject</button></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
` : "";
|
||||||
|
|
||||||
|
const userCellHtml = this.ctx.isManager
|
||||||
|
? `<td class="col-user">${this.escapeHtml(userDisp)}</td>`
|
||||||
|
: ``;
|
||||||
|
|
||||||
|
// Inline icon actions (no dropdown)
|
||||||
|
const editBtn = canEdit
|
||||||
|
? `<button type="button" class="icon-btn" title="Edit" aria-label="Edit"
|
||||||
|
data-action="edit" data-id="${id}">
|
||||||
|
<i class="bi bi-pencil-square text-warning fs-6"></i>
|
||||||
|
</button>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const approveBtn = (this.ctx.isManager && this.activeStatus === "Pending")
|
||||||
|
? `<button type="button" class="icon-btn" title="Approve" aria-label="Approve"
|
||||||
|
data-action="approve" data-id="${id}" ${approveDisabled}>
|
||||||
|
<i class="bi bi-check2-circle text-success fs-6"></i>
|
||||||
|
</button>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const rejectBtn = (this.ctx.isManager && this.activeStatus === "Pending")
|
||||||
|
? `<button type="button" class="icon-btn" title="Reject" aria-label="Reject"
|
||||||
|
data-action="reject" data-id="${id}" ${rejectDisabled}>
|
||||||
|
<i class="bi bi-x-circle text-danger fs-6"></i>
|
||||||
|
</button>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const cancelIcon = isCancelled ? "bi-arrow-counterclockwise" : "bi-slash-circle";
|
||||||
|
const cancelTitle = isCancelled ? "Un-cancel" : "Cancel";
|
||||||
|
const cancelClass = isCancelled ? "text-secondary" : "text-danger";
|
||||||
|
const cancelBtn = canCancel
|
||||||
|
? `<button type="button" class="icon-btn" title="${cancelTitle}" aria-label="${cancelTitle}"
|
||||||
|
data-action="toggle-cancel" data-id="${id}" data-status="${this.escapeHtml(status)}">
|
||||||
|
<i class="bi ${cancelIcon} ${cancelClass} fs-6"></i>
|
||||||
|
</button>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const actionsHtml = `
|
||||||
|
${editBtn}
|
||||||
|
${approveBtn}
|
||||||
|
${rejectBtn}
|
||||||
|
${cancelBtn}
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
const noteCell = hasNote
|
||||||
|
? `<button type="button" class="btn btn-sm btn-outline-primary" data-action="show-note" data-id="${id}">Notes</button>`
|
||||||
|
: `<span class="text-muted">—</span>`;
|
||||||
|
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="td-title">
|
||||||
|
<div class="line-clamp-2" title="${this.escapeHtml(title)}">${this.escapeHtml(title)}</div>
|
||||||
|
</td>
|
||||||
|
<td class="td-notes">
|
||||||
|
${noteCell}
|
||||||
|
</td>
|
||||||
|
<td>${this.escapeHtml(roomDisp)}</td>
|
||||||
|
<td>${this.escapeHtml(dateDisp)}</td>
|
||||||
|
<td>${this.escapeHtml(timeDisp)}</td>
|
||||||
|
${userCellHtml}
|
||||||
|
<td class="actions-col">
|
||||||
|
${actionsHtml.trim() ? actionsHtml : `<span class="text-muted">—</span>`}
|
||||||
|
</td>`;
|
||||||
|
$tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updatePager();
|
||||||
|
},
|
||||||
|
|
||||||
|
pedRowsSafe() { try { return this.pagedRows; } catch { return []; } },
|
||||||
|
|
||||||
|
// ===== server actions =====
|
||||||
|
async postAction(action, id, opts = {}) {
|
||||||
|
const url = new URL(`${this.api}`);
|
||||||
|
url.searchParams.set("action", action);
|
||||||
|
url.searchParams.set("id", id);
|
||||||
|
if (opts.undo) url.searchParams.set("undo", "1");
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
method: "POST",
|
||||||
|
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
|
||||||
|
body: opts.body ? JSON.stringify(opts.body) : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
if (!res.ok) throw new Error(text || `Failed (${res.status})`);
|
||||||
|
try { return JSON.parse(text); } catch { return {}; }
|
||||||
|
},
|
||||||
|
async handleApprove(id, btn) {
|
||||||
|
btn && (btn.disabled = true);
|
||||||
|
try { await this.postAction("approve", id); this.setAlert("success", "Booking approved."); await this.loadList(); }
|
||||||
|
catch (e) { this.setAlert("danger", e.message || "Approve failed."); }
|
||||||
|
finally { btn && (btn.disabled = false); }
|
||||||
|
},
|
||||||
|
async handleReject(id, btn) {
|
||||||
|
btn && (btn.disabled = true);
|
||||||
|
try { await this.postAction("reject", id); this.setAlert("success", "Booking rejected."); await this.loadList(); }
|
||||||
|
catch (e) { this.setAlert("danger", e.message || "Reject failed."); }
|
||||||
|
finally { btn && (btn.disabled = false); }
|
||||||
|
},
|
||||||
|
async handleToggleCancel(id, status, btn) {
|
||||||
|
btn && (btn.disabled = true);
|
||||||
|
const undo = String(status || "").toLowerCase() === "cancelled";
|
||||||
|
try { await this.postAction("cancel", id, { undo }); await this.loadList(); }
|
||||||
|
catch (e) { this.setAlert("danger", e.message || "Cancel action failed."); }
|
||||||
|
finally { btn && (btn.disabled = false); }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== notes popup =====
|
||||||
|
showNoteCard(id) {
|
||||||
|
const title = (this.filteredRows.find(r => String(r.id ?? r.Id ?? r.bookingId ?? r.BookingId) === String(id))?.title ?? "") + "";
|
||||||
|
const note = this.notesById.get(String(id)) ?? "";
|
||||||
|
|
||||||
|
const overlay = document.getElementById("noteOverlay");
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
const safeTitle = this.escapeHtml(title || "Notes");
|
||||||
|
const safeNote = this.escapeHtml(note || "(No notes)");
|
||||||
|
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="note-card" role="dialog" aria-modal="true">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<h6 class="mb-0">${safeTitle}</h6>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" data-note-close>Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="note-body">${safeNote.replace(/\\n/g, '<br/>')}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
|
||||||
|
const closeAll = () => { overlay.style.display = "none"; overlay.innerHTML = ""; };
|
||||||
|
overlay.onclick = (e) => { if (e.target === overlay) closeAll(); };
|
||||||
|
overlay.querySelector("[data-note-close]")?.addEventListener("click", closeAll);
|
||||||
|
document.addEventListener("keydown", function esc(e) { if (e.key === "Escape") { closeAll(); document.removeEventListener("keydown", esc); } });
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== filters & pager =====
|
||||||
|
bindFilterInputs() {
|
||||||
|
const bind = (id, setter) => {
|
||||||
|
const el = document.getElementById(id); if (!el) return;
|
||||||
|
const h = e => { this[setter] = e.target.value ?? ""; };
|
||||||
|
el.addEventListener("change", h); el.addEventListener("input", h);
|
||||||
|
};
|
||||||
|
bind("fltFrom", "fltFrom"); bind("fltTo", "fltTo"); bind("fltUser", "fltUser");
|
||||||
|
},
|
||||||
|
syncFiltersFromDom() {
|
||||||
|
const get = id => document.getElementById(id)?.value ?? "";
|
||||||
|
this.fltFrom = get("fltFrom"); this.fltTo = get("fltTo"); this.fltUser = get("fltUser");
|
||||||
|
},
|
||||||
|
applyFilters() { this.syncFiltersFromDom(); this.currentPage = 1; this.loadList(); },
|
||||||
|
clearFilters() {
|
||||||
|
this.fltFrom = this.fltTo = this.fltUser = "";
|
||||||
|
["fltFrom", "fltTo", "fltUser"].forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; });
|
||||||
|
this.currentPage = 1; this.loadList();
|
||||||
|
},
|
||||||
|
toggleFilters() { this.showFilters = !this.showFilters; },
|
||||||
|
|
||||||
|
updatePager() {
|
||||||
|
const total = this.filteredRows.length;
|
||||||
|
const totalPages = this.totalPages;
|
||||||
|
const pageInfo = document.getElementById("pageInfo");
|
||||||
|
const rangeInfo = document.getElementById("rangeInfo");
|
||||||
|
const btnPrev = document.getElementById("btnPrev");
|
||||||
|
const btnNext = document.getElementById("btnNext");
|
||||||
|
|
||||||
|
if (pageInfo) pageInfo.textContent = `Page ${this.currentPage} of ${totalPages}`;
|
||||||
|
const startIdx = total ? (this.currentPage - 1) * this.pageSize + 1 : 0;
|
||||||
|
const endIdx = Math.min(this.currentPage * this.pageSize, total);
|
||||||
|
if (rangeInfo) rangeInfo.textContent = `(Showing ${startIdx}–${endIdx} of ${total})`;
|
||||||
|
|
||||||
|
if (btnPrev) btnPrev.disabled = this.currentPage <= 1;
|
||||||
|
if (btnNext) btnNext.disabled = this.currentPage >= totalPages;
|
||||||
|
},
|
||||||
|
prevPage() { if (this.currentPage > 1) { this.currentPage--; this.renderTable(); this.updatePager(); } },
|
||||||
|
nextPage() { if (this.currentPage < this.totalPages) { this.currentPage++; this.renderTable(); this.updatePager(); } },
|
||||||
|
setPageSize(n) {
|
||||||
|
const newSize = Number(n) || 10;
|
||||||
|
if (newSize !== this.pageSize) {
|
||||||
|
this.pageSize = newSize; this.currentPage = 1; this.renderTable(); this.updatePager();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.fltFrom = document.getElementById("fltFrom")?.value ?? "";
|
||||||
|
this.fltTo = document.getElementById("fltTo")?.value ?? "";
|
||||||
|
this.fltUser = document.getElementById("fltUser")?.value ?? "";
|
||||||
|
|
||||||
|
this.bindFilterInputs();
|
||||||
|
|
||||||
|
document.getElementById("btnToggleFilters")?.addEventListener("click", this.toggleFilters);
|
||||||
|
document.getElementById("btnApply")?.addEventListener("click", this.applyFilters);
|
||||||
|
document.getElementById("btnClear")?.addEventListener("click", this.clearFilters);
|
||||||
|
document.getElementById("btnPrev")?.addEventListener("click", this.prevPage);
|
||||||
|
document.getElementById("btnNext")?.addEventListener("click", this.nextPage);
|
||||||
|
const selPageSize = document.getElementById("selPageSize");
|
||||||
|
if (selPageSize) {
|
||||||
|
selPageSize.value = String(this.pageSize);
|
||||||
|
selPageSize.addEventListener("change", e => this.setPageSize(e.target.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = document.getElementById("bookingsTable");
|
||||||
|
if (table) {
|
||||||
|
table.addEventListener("click", (e) => {
|
||||||
|
const el = e.target instanceof Element ? e.target : null; if (!el) return;
|
||||||
|
const btn = el.closest("[data-action]"); if (!btn) return;
|
||||||
|
|
||||||
|
const action = btn.getAttribute("data-action");
|
||||||
|
const id = btn.getAttribute("data-id");
|
||||||
|
if (!action || !id) return;
|
||||||
|
|
||||||
|
if (action === "approve") return this.handleApprove(id, btn);
|
||||||
|
if (action === "reject") return this.handleReject(id, btn);
|
||||||
|
if (action === "toggle-cancel") {
|
||||||
|
const status = btn.getAttribute("data-status") || "";
|
||||||
|
return this.handleToggleCancel(id, status, btn);
|
||||||
|
}
|
||||||
|
if (action === "show-note") { return this.showNoteCard(id); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", (e) => {
|
||||||
|
const el = e.target instanceof Element ? e.target : null; if (!el) return;
|
||||||
|
const btn = el.closest("[data-action]"); if (!btn) return;
|
||||||
|
|
||||||
|
const action = btn.getAttribute("data-action");
|
||||||
|
const id = btn.getAttribute("data-id");
|
||||||
|
if (!action || !id) return;
|
||||||
|
|
||||||
|
if (action === "approve") return this.handleApprove(id, btn);
|
||||||
|
if (action === "reject") return this.handleReject(id, btn);
|
||||||
|
if (action === "toggle-cancel") {
|
||||||
|
const status = btn.getAttribute("data-status") || "";
|
||||||
|
return this.handleToggleCancel(id, status, btn);
|
||||||
|
}
|
||||||
|
if (action === "edit") {
|
||||||
|
const url = '@Url.Action("Create", "Bookings", new { area = "Bookings" })' + `?id=${encodeURIComponent(id)}`;
|
||||||
|
return window.location.assign(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "show-note") { return this.showNoteCard(id); }
|
||||||
|
});
|
||||||
|
|
||||||
|
this.loadLookups().then(() => this.loadList());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.mount("#app");
|
||||||
|
</script>
|
||||||
|
}
|
||||||
227
Areas/Bookings/Views/Bookings/Managers.cshtml
Normal file
227
Areas/Bookings/Views/Bookings/Managers.cshtml
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "Booking Managers";
|
||||||
|
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||||
|
}
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 6px 18px rgba(0,0,0,.06);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background: #f9fbff;
|
||||||
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
border-top-left-radius: 14px;
|
||||||
|
border-top-right-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-list {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #eef2f7;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-item:hover {
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-selected {
|
||||||
|
min-height: 48px;
|
||||||
|
border: 1px dashed #dbe3ef;
|
||||||
|
background: #fbfdff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
padding: .28rem .5rem;
|
||||||
|
margin: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #3949ab;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-x {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-x:hover {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="mgrApp" style="max-width:1000px; margin:auto; font-size:13px;">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="m-0">Booking Managers</h5>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" @@click="loadAll" :disabled="busy">Refresh</button>
|
||||||
|
<button class="btn btn-primary btn-sm ms-2" @@click="save" :disabled="saving || busy">
|
||||||
|
{{ saving ? 'Saving…' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
|
||||||
|
<div class="text-muted mb-3">
|
||||||
|
<small>Select users who should act as <strong>Managers</strong> for the Room Booking module (approve/reject, manage rooms, etc.).</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 align-items-start">
|
||||||
|
<!-- LEFT: Search + Available users -->
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="input-group input-group-sm mb-2">
|
||||||
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="text" class="form-control" placeholder="Search users by name…" v-model.trim="q">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="it-list">
|
||||||
|
<label v-for="u in filteredUsers" :key="'u-'+u.id" class="it-item">
|
||||||
|
<input type="checkbox" :value="u.id" v-model="managerIds" />
|
||||||
|
<span class="it-name">{{ u.name }}</span>
|
||||||
|
<small class="text-muted ms-1">({{ u.email }})</small>
|
||||||
|
</label>
|
||||||
|
<div v-if="!filteredUsers.length" class="text-muted small p-2">No users match your search.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT: Selected chips -->
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<strong>Selected ({{ selectedUsers.length }})</strong>
|
||||||
|
<button class="btn btn-link btn-sm text-decoration-none"
|
||||||
|
@@click="managerIds = []"
|
||||||
|
:disabled="!selectedUsers.length">
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="it-selected">
|
||||||
|
<span v-for="u in selectedUsers" :key="'sel-'+u.id" class="chip">
|
||||||
|
{{ u.name }}
|
||||||
|
<button class="chip-x" @@click="remove(u.id)" aria-label="Remove">×</button>
|
||||||
|
</span>
|
||||||
|
<div v-if="!selectedUsers.length" class="text-muted small">Nobody selected yet.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 form-text">
|
||||||
|
Managers can: approve/reject bookings, create/update rooms, cancel/un-cancel any booking, etc.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const mgrApp = Vue.createApp({
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
busy: false,
|
||||||
|
saving: false,
|
||||||
|
error: null,
|
||||||
|
q: '',
|
||||||
|
users: [], // {id, name, email}
|
||||||
|
managerIds: [] // [int]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
filteredUsers() {
|
||||||
|
const k = (this.q || '').toLowerCase();
|
||||||
|
return !k ? this.users : this.users.filter(u =>
|
||||||
|
(u.name || '').toLowerCase().includes(k) || (u.email || '').toLowerCase().includes(k)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
selectedUsers() {
|
||||||
|
const set = new Set(this.managerIds);
|
||||||
|
return this.users.filter(u => set.has(u.id));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async loadUsers() {
|
||||||
|
const r = await fetch('/api/BookingsApi/users');
|
||||||
|
if (!r.ok) throw new Error('Failed to load users');
|
||||||
|
this.users = await r.json();
|
||||||
|
},
|
||||||
|
async loadManagers() {
|
||||||
|
const r = await fetch('/api/BookingsApi/managers');
|
||||||
|
if (!r.ok) throw new Error('Failed to load managers');
|
||||||
|
this.managerIds = await r.json(); // array<int>
|
||||||
|
},
|
||||||
|
async loadAll() {
|
||||||
|
try {
|
||||||
|
this.busy = true; this.error = null;
|
||||||
|
await Promise.all([this.loadUsers(), this.loadManagers()]);
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message || 'Load failed.';
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
remove(id) {
|
||||||
|
this.managerIds = this.managerIds.filter(x => x !== id);
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
try {
|
||||||
|
this.saving = true; this.error = null;
|
||||||
|
const r = await fetch('/api/BookingsApi/managers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userIds: this.managerIds })
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
throw new Error(j.message || `Save failed (${r.status})`);
|
||||||
|
}
|
||||||
|
alert('Managers updated.');
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message || 'Save failed.';
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
await this.loadAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
mgrApp.mount('#mgrApp');
|
||||||
|
</script>
|
||||||
268
Areas/Bookings/Views/Bookings/Room.cshtml
Normal file
268
Areas/Bookings/Views/Bookings/Room.cshtml
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Rooms";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
<style>
|
||||||
|
.table-container {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,.1);
|
||||||
|
padding: 25px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th, .table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-blue {
|
||||||
|
background: #cfe2f3 !important
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-green {
|
||||||
|
background: #d9ead3 !important
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-orange {
|
||||||
|
background: #fce5cd !important
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn, input.form-control, select.form-control {
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive {
|
||||||
|
overflow-x: auto
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2 class="mb-3"></h2>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#roomModal" onclick="openCreate()">New Room</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="alerts"></div>
|
||||||
|
|
||||||
|
<div class="table-responsive table-container">
|
||||||
|
<table class="table table-bordered table-striped align-middle" id="roomsTable">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="header-blue">Name</th>
|
||||||
|
<th class="header-blue">Location</th>
|
||||||
|
<th class="header-green">Capacity</th>
|
||||||
|
<th class="header-green">Active</th>
|
||||||
|
<th class="header-orange">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody><tr><td colspan="5" class="text-center">Loading…</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div class="modal fade" id="roomModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content" style="border-radius:14px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="roomModalTitle">New Room</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="hidden" id="RoomId" />
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Room Name</label>
|
||||||
|
<input id="RoomName" class="form-control" maxlength="120" required />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Location Code</label>
|
||||||
|
<input id="LocationCode" class="form-control" maxlength="40" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Capacity</label>
|
||||||
|
<input id="Capacity" class="form-control" type="number" min="0" />
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="IsActive" checked />
|
||||||
|
<label class="form-check-label" for="IsActive">Active</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||||
|
<button class="btn btn-success" id="saveBtn" onclick="saveRoom()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
// --- Constants / refs ---
|
||||||
|
const api = `${window.location.origin}/api/BookingsApi`;
|
||||||
|
const tbody = document.querySelector("#roomsTable tbody");
|
||||||
|
|
||||||
|
// ---------- UI helpers ----------
|
||||||
|
function showAlert(type, msg) {
|
||||||
|
document.getElementById("alerts").innerHTML = `
|
||||||
|
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||||
|
${msg}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s ?? "").replace(/[&<>\"']/g, m =>
|
||||||
|
({
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'"
|
||||||
|
}[m])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Data load ----------
|
||||||
|
async function loadRooms() {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="5" class="text-center">Loading…</td></tr>`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${api}?scope=rooms`);
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!Array.isArray(data) || data.length === 0) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="5" class="text-center">No rooms yet.</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
for (const r of data) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${escapeHtml(r.roomName)}</td>
|
||||||
|
<td>${escapeHtml(r.locationCode ?? "")}</td>
|
||||||
|
<td>${r.capacity ?? ""}</td>
|
||||||
|
<td>
|
||||||
|
${r.isActive
|
||||||
|
? '<span class="badge bg-success">Yes</span>'
|
||||||
|
: '<span class="badge bg-secondary">No</span>'
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-warning me-2"
|
||||||
|
onclick='openEdit(${r.roomId},"${escapeHtml(
|
||||||
|
r.roomName
|
||||||
|
)}","${escapeHtml(r.locationCode ?? "")}",${r.capacity ?? "null"
|
||||||
|
},${r.isActive})'>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger"
|
||||||
|
onclick="deleteRoom(${r.roomId})">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
tbody.innerHTML = `<tr><td colspan="5" class="text-danger text-center">Failed: ${escapeHtml(
|
||||||
|
err.message
|
||||||
|
)}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Modal helpers ----------
|
||||||
|
window.openCreate = function () {
|
||||||
|
document.getElementById("roomModalTitle").textContent = "New Room";
|
||||||
|
document.getElementById("RoomId").value = "";
|
||||||
|
document.getElementById("RoomName").value = "";
|
||||||
|
document.getElementById("LocationCode").value = "";
|
||||||
|
document.getElementById("Capacity").value = "";
|
||||||
|
document.getElementById("IsActive").checked = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.openEdit = function (id, name, loc, cap, active) {
|
||||||
|
document.getElementById("roomModalTitle").textContent = "Edit Room";
|
||||||
|
document.getElementById("RoomId").value = id;
|
||||||
|
document.getElementById("RoomName").value = name;
|
||||||
|
document.getElementById("LocationCode").value = loc === "null" ? "" : loc;
|
||||||
|
document.getElementById("Capacity").value = cap === null ? "" : cap;
|
||||||
|
document.getElementById("IsActive").checked = !!active;
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById("roomModal"));
|
||||||
|
modal.show();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Save / Delete ----------
|
||||||
|
window.saveRoom = async function () {
|
||||||
|
const id = document.getElementById("RoomId").value;
|
||||||
|
const roomName = document.getElementById("RoomName").value.trim();
|
||||||
|
const locationCode = document.getElementById("LocationCode").value.trim();
|
||||||
|
const capacity = document.getElementById("Capacity").value
|
||||||
|
? Number(document.getElementById("Capacity").value)
|
||||||
|
: null;
|
||||||
|
const isActive = document.getElementById("IsActive").checked;
|
||||||
|
|
||||||
|
if (!roomName) {
|
||||||
|
showAlert("danger", "Room Name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
const payload = {
|
||||||
|
RoomName: roomName,
|
||||||
|
LocationCode: locationCode || null,
|
||||||
|
Capacity: capacity,
|
||||||
|
IsActive: isActive
|
||||||
|
};
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
res = await fetch(`${api}?scope=rooms&id=${encodeURIComponent(id)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res = await fetch(`${api}?scope=rooms`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
|
||||||
|
showAlert("success", "Room saved.");
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById("roomModal"))?.hide();
|
||||||
|
await loadRooms();
|
||||||
|
} catch (err) {
|
||||||
|
showAlert("danger", "Save failed: " + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.deleteRoom = async function (id) {
|
||||||
|
if (!confirm("Delete this room?")) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${api}?scope=rooms&id=${encodeURIComponent(id)}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
|
||||||
|
showAlert("success", "Room deleted.");
|
||||||
|
await loadRooms();
|
||||||
|
} catch (err) {
|
||||||
|
showAlert("danger", "Delete failed: " + (err?.message || ""));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- Boot ----------
|
||||||
|
document.addEventListener("DOMContentLoaded", loadRooms);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
47
Areas/IT/Controllers/ApprovalDashboardController.cs
Normal file
47
Areas/IT/Controllers/ApprovalDashboardController.cs
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
Areas/IT/Models/ItApprovalFlow.cs
Normal file
21
Areas/IT/Models/ItApprovalFlow.cs
Normal file
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
56
Areas/IT/Models/ItRequest.cs
Normal file
56
Areas/IT/Models/ItRequest.cs
Normal file
@ -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<ItRequestHardware> Hardware { get; set; } = new List<ItRequestHardware>();
|
||||||
|
public ICollection<ItRequestEmail> Emails { get; set; } = new List<ItRequestEmail>();
|
||||||
|
public ICollection<ItRequestOsRequirement> OsRequirements { get; set; } = new List<ItRequestOsRequirement>();
|
||||||
|
public ICollection<ItRequestSoftware> Software { get; set; } = new List<ItRequestSoftware>();
|
||||||
|
public ICollection<ItRequestSharedPermission> SharedPermissions { get; set; } = new List<ItRequestSharedPermission>();
|
||||||
|
|
||||||
|
public DateTime? FirstSubmittedAt { get; set; } // when the request was first created
|
||||||
|
public DateTime? EditableUntil { get; set; } // FirstSubmittedAt + window
|
||||||
|
public bool IsLockedForEdit { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
36
Areas/IT/Models/ItRequestAssetInfo.cs
Normal file
36
Areas/IT/Models/ItRequestAssetInfo.cs
Normal file
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
24
Areas/IT/Models/ItRequestEmail.cs
Normal file
24
Areas/IT/Models/ItRequestEmail.cs
Normal file
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
26
Areas/IT/Models/ItRequestHardware.cs
Normal file
26
Areas/IT/Models/ItRequestHardware.cs
Normal file
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Areas/IT/Models/ItRequestOsRequirement.cs
Normal file
18
Areas/IT/Models/ItRequestOsRequirement.cs
Normal file
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
24
Areas/IT/Models/ItRequestSharedPermission.cs
Normal file
24
Areas/IT/Models/ItRequestSharedPermission.cs
Normal file
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
26
Areas/IT/Models/ItRequestSoftware.cs
Normal file
26
Areas/IT/Models/ItRequestSoftware.cs
Normal file
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
37
Areas/IT/Models/ItRequestStatus.cs
Normal file
37
Areas/IT/Models/ItRequestStatus.cs
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
12
Areas/IT/Models/ItTeamMember.cs
Normal file
12
Areas/IT/Models/ItTeamMember.cs
Normal file
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
730
Areas/IT/Printing/ItRequestPdfService.cs
Normal file
730
Areas/IT/Printing/ItRequestPdfService.cs
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
79
Areas/IT/Printing/ItRequestReportModel.cs
Normal file
79
Areas/IT/Printing/ItRequestReportModel.cs
Normal file
@ -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<string> Hardware { get; set; } = new();
|
||||||
|
public string? Justification { get; set; }
|
||||||
|
public List<string> Emails { get; set; } = new();
|
||||||
|
public List<string> 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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
473
Areas/IT/Views/ApprovalDashboard/Admin.cshtml
Normal file
473
Areas/IT/Views/ApprovalDashboard/Admin.cshtml
Normal file
@ -0,0 +1,473 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "IT Request Assignments";
|
||||||
|
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- Bootstrap Icons (kill this if you already include it in _Layout) -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card {
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 6px 18px rgba(0,0,0,.06);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
background: #f9fbff;
|
||||||
|
border-bottom: 1px solid #eef2f7;
|
||||||
|
border-top-left-radius: 14px;
|
||||||
|
border-top-right-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 6px 18px rgba(0,0,0,.06);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-110 {
|
||||||
|
width: 110px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === IT Team nicer UI === */
|
||||||
|
.it-card .card-header {
|
||||||
|
background: #f8faff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-list {
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #eef2f7;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-item:hover {
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-item input {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-selected {
|
||||||
|
min-height: 48px;
|
||||||
|
border: 1px dashed #dbe3ef;
|
||||||
|
background: #fbfdff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .4rem;
|
||||||
|
padding: .28rem .5rem;
|
||||||
|
margin: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #3949ab;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-x {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-x:hover {
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="flowApp" style="max-width:1200px; margin:auto; font-size:13px;">
|
||||||
|
|
||||||
|
<!-- FLOWS CARD -->
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="m-0"></h5>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-primary btn-sm" @@click="openCreate">New Flow</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm ms-2" @@click="load">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
|
||||||
|
<div v-if="busy" class="alert alert-secondary py-2">Loading…</div>
|
||||||
|
|
||||||
|
<div class="table-container table-responsive">
|
||||||
|
<table class="table table-bordered table-sm table-striped align-middle text-center">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="w-110">Flow ID</th>
|
||||||
|
<th>Flow Name</th>
|
||||||
|
<th>HOD</th>
|
||||||
|
<th>Group IT HOD</th>
|
||||||
|
<th>FIN HOD</th>
|
||||||
|
<th>MGMT</th>
|
||||||
|
<th style="width:200px;">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="f in flows" :key="f.itApprovalFlowId">
|
||||||
|
<td>{{ f.itApprovalFlowId }}</td>
|
||||||
|
<td class="text-start">{{ f.flowName }}</td>
|
||||||
|
|
||||||
|
<!-- show id + resolved name so admins aren't guessing -->
|
||||||
|
<td>{{ f.hodUserId ? `${f.hodUserId} — ${resolveUserName(f.hodUserId)}` : '-' }}</td>
|
||||||
|
<td>{{ f.groupItHodUserId ? `${f.groupItHodUserId} — ${resolveUserName(f.groupItHodUserId)}` : '-' }}</td>
|
||||||
|
<td>{{ f.finHodUserId ? `${f.finHodUserId} — ${resolveUserName(f.finHodUserId)}` : '-' }}</td>
|
||||||
|
<td>{{ f.mgmtUserId ? `${f.mgmtUserId} — ${resolveUserName(f.mgmtUserId)}` : '-' }}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" @@click="openEdit(f)">Edit</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" @@click="del(f)">Delete</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!flows.length && !busy">
|
||||||
|
<td colspan="7" class="text-muted">No flows yet. Click “New Flow”.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-muted">
|
||||||
|
<small>
|
||||||
|
Heads up: <code>/ItRequestAPI/create</code> uses the first flow in the DB. Make sure one exists w/ approvers.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IT TEAM CARD -->
|
||||||
|
<div class="card mt-3 it-card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="m-0">IT Team Members</h6>
|
||||||
|
<button class="btn btn-sm btn-primary" @@click="saveItTeam" :disabled="savingTeam">
|
||||||
|
{{ savingTeam ? 'Saving…' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-muted mb-3">
|
||||||
|
<small>Select existing users to mark them as IT Team (they can edit Section B + do IT acceptance).</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 align-items-start">
|
||||||
|
<!-- LEFT: Search + Available -->
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="input-group input-group-sm mb-2">
|
||||||
|
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="text" class="form-control" placeholder="Search users by name…" v-model.trim="itSearch">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="it-list">
|
||||||
|
<label v-for="u in filteredUsers" :key="'avail-'+u.id" class="it-item">
|
||||||
|
<input type="checkbox" :value="u.id" v-model="itTeamUserIds">
|
||||||
|
<span class="it-name">{{ u.name }}</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="!filteredUsers.length" class="text-muted small p-2">
|
||||||
|
No users match your search.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT: Selected chips -->
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<strong>Selected ({{ selectedUsers.length }})</strong>
|
||||||
|
<button class="btn btn-link btn-sm text-decoration-none"
|
||||||
|
@@click="itTeamUserIds = []"
|
||||||
|
:disabled="!selectedUsers.length">
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="it-selected">
|
||||||
|
<span v-for="u in selectedUsers" :key="'sel-'+u.id" class="chip">
|
||||||
|
{{ u.name }}
|
||||||
|
<button class="chip-x" @@click="removeIt(u.id)" aria-label="Remove">×</button>
|
||||||
|
</span>
|
||||||
|
<div v-if="!selectedUsers.length" class="text-muted small">Nobody selected yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FLOW MODAL -->
|
||||||
|
<div class="modal fade" id="flowModal" tabindex="-1" aria-hidden="true" ref="modal">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h6 class="modal-title">{{ form.itApprovalFlowId ? 'Edit Flow' : 'Create Flow' }}</h6>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" @@click="closeModal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div v-if="formError" class="alert alert-danger py-2">{{ formError }}</div>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label">Flow Name</label>
|
||||||
|
<input class="form-control form-control-sm" v-model.trim="form.flowName" placeholder="e.g., Default Flow">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">HOD Approver</label>
|
||||||
|
<select class="form-select form-select-sm" v-model.number="form.hodUserId">
|
||||||
|
<option :value="null">— None —</option>
|
||||||
|
<option v-for="u in users" :key="'hod-'+u.id" :value="u.id">{{ u.name }}</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Approver for HOD stage</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Group IT HOD</label>
|
||||||
|
<select class="form-select form-select-sm" v-model.number="form.groupItHodUserId">
|
||||||
|
<option :value="null">— None —</option>
|
||||||
|
<option v-for="u in users" :key="'git-'+u.id" :value="u.id">{{ u.name }}</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Approver for Group IT HOD</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Finance HOD</label>
|
||||||
|
<select class="form-select form-select-sm" v-model.number="form.finHodUserId">
|
||||||
|
<option :value="null">— None —</option>
|
||||||
|
<option v-for="u in users" :key="'fin-'+u.id" :value="u.id">{{ u.name }}</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Approver for Finance HOD</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label">Management</label>
|
||||||
|
<select class="form-select form-select-sm" v-model.number="form.mgmtUserId">
|
||||||
|
<option :value="null">— None —</option>
|
||||||
|
<option v-for="u in users" :key="'mgmt-'+u.id" :value="u.id">{{ u.name }}</option>
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Final management approver</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal" @@click="closeModal">Cancel</button>
|
||||||
|
<button class="btn btn-primary btn-sm" :disabled="saving" @@click="save">
|
||||||
|
{{ saving ? 'Saving…' : 'Save' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const flowApp = Vue.createApp({
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
flows: [],
|
||||||
|
users: [], // all users (name only for display)
|
||||||
|
itTeamUserIds: [], // selected user IDs for IT team
|
||||||
|
itSearch: '', // search term
|
||||||
|
busy: false,
|
||||||
|
error: null,
|
||||||
|
saving: false,
|
||||||
|
savingTeam: false,
|
||||||
|
formError: null,
|
||||||
|
form: {
|
||||||
|
itApprovalFlowId: null,
|
||||||
|
flowName: '',
|
||||||
|
hodUserId: null,
|
||||||
|
groupItHodUserId: null,
|
||||||
|
finHodUserId: null,
|
||||||
|
mgmtUserId: null
|
||||||
|
},
|
||||||
|
bsModal: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
filteredUsers() {
|
||||||
|
const q = (this.itSearch || '').toLowerCase();
|
||||||
|
if (!q) return this.users;
|
||||||
|
return this.users.filter(u => (u.name || '').toLowerCase().includes(q));
|
||||||
|
},
|
||||||
|
selectedUsers() {
|
||||||
|
const set = new Set(this.itTeamUserIds);
|
||||||
|
return this.users.filter(u => set.has(u.id));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
this.busy = true; this.error = null;
|
||||||
|
const r = await fetch('/ItRequestAPI/flows');
|
||||||
|
if (!r.ok) throw new Error(`Load failed (${r.status})`);
|
||||||
|
const data = await r.json();
|
||||||
|
this.flows = Array.isArray(data)
|
||||||
|
? data.map(x => ({
|
||||||
|
itApprovalFlowId: x.itApprovalFlowId ?? x.ItApprovalFlowId,
|
||||||
|
flowName: x.flowName ?? x.FlowName,
|
||||||
|
hodUserId: x.hodUserId ?? x.HodUserId,
|
||||||
|
groupItHodUserId: x.groupItHodUserId ?? x.GroupItHodUserId,
|
||||||
|
finHodUserId: x.finHodUserId ?? x.FinHodUserId,
|
||||||
|
mgmtUserId: x.mgmtUserId ?? x.MgmtUserId
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message || 'Failed to load flows.';
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadUsers() {
|
||||||
|
const res = await fetch('/ItRequestAPI/users');
|
||||||
|
this.users = await res.json();
|
||||||
|
},
|
||||||
|
async loadItTeam() {
|
||||||
|
const r = await fetch('/ItRequestAPI/itTeam');
|
||||||
|
this.itTeamUserIds = await r.json(); // array<int>
|
||||||
|
},
|
||||||
|
resolveUserName(id) {
|
||||||
|
const u = this.users.find(x => x.id === id);
|
||||||
|
return u ? u.name : '(unknown)';
|
||||||
|
},
|
||||||
|
openCreate() {
|
||||||
|
this.formError = null;
|
||||||
|
this.form = { itApprovalFlowId: null, flowName: '', hodUserId: null, groupItHodUserId: null, finHodUserId: null, mgmtUserId: null };
|
||||||
|
this.showModal();
|
||||||
|
this.loadUsers();
|
||||||
|
},
|
||||||
|
openEdit(f) {
|
||||||
|
this.formError = null;
|
||||||
|
this.form = JSON.parse(JSON.stringify(f));
|
||||||
|
this.showModal();
|
||||||
|
this.loadUsers();
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
try {
|
||||||
|
if (this.saving) return;
|
||||||
|
this.saving = true; this.formError = null;
|
||||||
|
|
||||||
|
if (!this.form.flowName || !this.form.flowName.trim()) {
|
||||||
|
this.formError = 'Flow name is required.'; this.saving = false; return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
flowName: this.form.flowName.trim(),
|
||||||
|
hodUserId: this.nullIfEmpty(this.form.hodUserId),
|
||||||
|
groupItHodUserId: this.nullIfEmpty(this.form.groupItHodUserId),
|
||||||
|
finHodUserId: this.nullIfEmpty(this.form.finHodUserId),
|
||||||
|
mgmtUserId: this.nullIfEmpty(this.form.mgmtUserId)
|
||||||
|
};
|
||||||
|
|
||||||
|
let res;
|
||||||
|
if (this.form.itApprovalFlowId) {
|
||||||
|
res = await fetch(`/ItRequestAPI/flows/${this.form.itApprovalFlowId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res = await fetch('/ItRequestAPI/flows', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.message || `Save failed (${res.status})`);
|
||||||
|
}
|
||||||
|
await this.load();
|
||||||
|
this.closeModal();
|
||||||
|
} catch (e) {
|
||||||
|
this.formError = e.message || 'Unable to save flow.';
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async del(f) {
|
||||||
|
if (!confirm(`Delete flow "${f.flowName}"?`)) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/ItRequestAPI/flows/${f.itApprovalFlowId}`, { method: 'DELETE' });
|
||||||
|
if (!r.ok) {
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
throw new Error(j.message || `Delete failed (${r.status})`);
|
||||||
|
}
|
||||||
|
await this.load();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || 'Delete failed.');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
nullIfEmpty(v) {
|
||||||
|
if (v === undefined || v === null || v === '') return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
},
|
||||||
|
showModal() {
|
||||||
|
const el = document.getElementById('flowModal');
|
||||||
|
this.bsModal = new bootstrap.Modal(el);
|
||||||
|
this.bsModal.show();
|
||||||
|
},
|
||||||
|
closeModal() {
|
||||||
|
if (this.bsModal) this.bsModal.hide();
|
||||||
|
},
|
||||||
|
removeIt(uid) {
|
||||||
|
this.itTeamUserIds = this.itTeamUserIds.filter(id => id !== uid);
|
||||||
|
},
|
||||||
|
async saveItTeam() {
|
||||||
|
try {
|
||||||
|
this.savingTeam = true;
|
||||||
|
const r = await fetch('/ItRequestAPI/itTeam', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userIds: this.itTeamUserIds })
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
throw new Error(j.message || `Save failed (${r.status})`);
|
||||||
|
}
|
||||||
|
alert('IT Team updated.');
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || 'Failed to update IT Team.');
|
||||||
|
} finally {
|
||||||
|
this.savingTeam = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
await Promise.all([this.load(), this.loadUsers(), this.loadItTeam()]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
flowApp.mount('#flowApp');
|
||||||
|
</script>
|
||||||
475
Areas/IT/Views/ApprovalDashboard/Approval.cshtml
Normal file
475
Areas/IT/Views/ApprovalDashboard/Approval.cshtml
Normal file
@ -0,0 +1,475 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "IT Request Approval Board";
|
||||||
|
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||||
|
}
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.table-container {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 6px 18px rgba(0,0,0,.06);
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
table-layout: fixed;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container th,
|
||||||
|
.table-container td {
|
||||||
|
word-wrap: break-word;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters .form-label {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs .badge {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badges .badge + .badge {
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 18px 0 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="app" style="max-width:1300px; margin:auto; font-size:13px;">
|
||||||
|
<h3 class="mb-2 fw-bold"></h3>
|
||||||
|
<p class="text-muted mb-3" style="margin-top:-6px;">Manage approvals and track Section B progress by month.</p>
|
||||||
|
|
||||||
|
<!-- Filters (shared by both tables) -->
|
||||||
|
<div class="row mb-3 align-items-end filters">
|
||||||
|
<div class="col-md-auto me-3">
|
||||||
|
<label class="form-label">Month</label>
|
||||||
|
<select class="form-control form-control-sm" v-model="selectedMonth" @@change="onPeriodChange">
|
||||||
|
<option v-for="(m,i) in months" :key="i" :value="i+1">{{ m }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-auto">
|
||||||
|
<label class="form-label">Year</label>
|
||||||
|
<select class="form-control form-control-sm" v-model="selectedYear" @@change="onPeriodChange">
|
||||||
|
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
|
||||||
|
<div v-if="busy" class="alert alert-secondary py-2">Loading…</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ========================= -->
|
||||||
|
<!-- TABLE 1: Approvals Board -->
|
||||||
|
<!-- ========================= -->
|
||||||
|
<template v-if="isApprover">
|
||||||
|
<h5 class="section-title">Approvals</h5>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs mb-3">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" :class="{ active: activeTab==='pending' }" href="#" @@click.prevent="switchTab('pending')">
|
||||||
|
Pending <span class="badge bg-warning text-dark">{{ pendingActionsCount }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" :class="{ active: activeTab==='completed' }" href="#" @@click.prevent="switchTab('completed')">
|
||||||
|
Completed <span class="badge bg-info">{{ completedActionsCount }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="table-container table-responsive">
|
||||||
|
<table class="table table-bordered table-sm table-striped align-middle text-center">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Staff Name</th>
|
||||||
|
<th>Department</th>
|
||||||
|
<th>Date Submitted</th>
|
||||||
|
<th>Stage / Role</th>
|
||||||
|
<th>Your Status</th>
|
||||||
|
<th style="width:260px;">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in paginatedData" :key="row.statusId">
|
||||||
|
<td>{{ row.staffName }}</td>
|
||||||
|
<td>{{ row.departmentName }}</td>
|
||||||
|
<td>{{ formatDate(row.submitDate) }}</td>
|
||||||
|
<td><span class="badge bg-light text-dark">{{ row.role }}</span></td>
|
||||||
|
<td class="status-badges">
|
||||||
|
<span :class="getStatusBadgeClass(row.currentUserStatus)">{{ row.currentUserStatus }}</span>
|
||||||
|
<span v-if="row.isOverallRejected && !['Approved','Rejected'].includes(row.currentUserStatus)" class="badge bg-danger">Rejected earlier</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex justify-content-center align-items-center">
|
||||||
|
<template v-if="activeTab==='pending'">
|
||||||
|
<button v-if="row.canApprove" class="btn btn-success btn-sm me-1" @@click="updateStatus(row.statusId,'Approved')" :disabled="busy">Approve</button>
|
||||||
|
<button v-if="row.canApprove" class="btn btn-danger btn-sm me-1" @@click="updateStatus(row.statusId,'Rejected')" :disabled="busy">Reject</button>
|
||||||
|
<!-- removed 'awaiting previous stage' badge entirely -->
|
||||||
|
</template>
|
||||||
|
<button class="btn btn-primary btn-sm" @@click="viewRequest(row.statusId)">View</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!paginatedData.length"><td colspan="6" class="text-muted">No {{ activeTab }} requests</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Pagination (Approvals) -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-2" v-if="filteredData.length">
|
||||||
|
<small class="text-muted">
|
||||||
|
Showing {{ (currentPage-1)*itemsPerPage + 1 }} – {{ Math.min(currentPage*itemsPerPage, filteredData.length) }} of {{ filteredData.length }}
|
||||||
|
</small>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" :disabled="currentPage===1" @@click="currentPage--">Prev</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" :disabled="currentPage*itemsPerPage>=filteredData.length" @@click="currentPage++">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ========================= -->
|
||||||
|
<!-- TABLE 2: Section B Board -->
|
||||||
|
<!-- ========================= -->
|
||||||
|
<template v-if="isItMember">
|
||||||
|
<h5 class="section-title">Section B</h5>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs mb-3">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" :class="{ active: activeSbTab==='draft' }" href="#" @@click.prevent="switchSbTab('draft')">
|
||||||
|
Draft <span class="badge bg-info text-dark">{{ sbCountDraft }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" :class="{ active: activeSbTab==='pending' }" href="#" @@click.prevent="switchSbTab('pending')">
|
||||||
|
Pending <span class="badge bg-secondary">{{ sbCountPending }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" :class="{ active: activeSbTab==='awaiting' }" href="#" @@click.prevent="switchSbTab('awaiting')">
|
||||||
|
Awaiting <span class="badge bg-warning text-dark">{{ sbCountAwaiting }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" :class="{ active: activeSbTab==='complete' }" href="#" @@click.prevent="switchSbTab('complete')">
|
||||||
|
Complete <span class="badge bg-success">{{ sbCountComplete }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="table-container table-responsive">
|
||||||
|
<table class="table table-bordered table-sm table-striped align-middle text-center">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Staff Name</th>
|
||||||
|
<th>Department</th>
|
||||||
|
<th>Approved On</th>
|
||||||
|
<th>Section B Stage</th>
|
||||||
|
<th style="width:360px;">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in sbPaginated" :key="r.statusId">
|
||||||
|
<td>{{ r.staffName }}</td>
|
||||||
|
<td>{{ r.departmentName }}</td>
|
||||||
|
<td>{{ r.approvedAt ? formatDate(r.approvedAt) : '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="stageBadge(r.stage).cls">{{ stageBadge(r.stage).text }}</span>
|
||||||
|
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex justify-content-center align-items-center flex-wrap" style="gap:6px;">
|
||||||
|
|
||||||
|
<!-- 1️⃣ Pending -->
|
||||||
|
<button v-if="r.stage==='PENDING'"
|
||||||
|
class="btn btn-outline-dark btn-sm"
|
||||||
|
@@click="openSectionB(r.statusId)">
|
||||||
|
Start Section B
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- DRAFT -->
|
||||||
|
<button v-if="r.stage==='DRAFT'"
|
||||||
|
class="btn btn-outline-primary btn-sm"
|
||||||
|
@@click="openSectionBEdit(r.statusId)">
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- AWAITING -->
|
||||||
|
<button v-if="r.stage==='AWAITING' && !r.sb.itAccepted"
|
||||||
|
class="btn btn-success btn-sm"
|
||||||
|
@@click="acceptIt(r.statusId)">
|
||||||
|
Accept
|
||||||
|
</button>
|
||||||
|
<button v-if="r.stage==='AWAITING' && r.sb.itAccepted"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
@@click="openSectionB(r.statusId)">
|
||||||
|
Review Section B
|
||||||
|
</button>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 4️⃣ Complete -->
|
||||||
|
<button v-if="r.stage==='COMPLETE'"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
@@click="openSectionB(r.statusId)">
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<button v-if="r.stage==='COMPLETE'"
|
||||||
|
class="btn btn-outline-secondary btn-sm"
|
||||||
|
@@click="downloadPdf(r.statusId)">
|
||||||
|
PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr v-if="!busy && sbFiltered.length===0">
|
||||||
|
<td colspan="5" class="text-muted text-center"><i class="bi bi-inboxes"></i> No Section B items in this tab</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Pagination (Section B) -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-2" v-if="sbFiltered.length">
|
||||||
|
<small class="text-muted">
|
||||||
|
Showing {{ (sectionBPageIndex-1)*itemsPerPage + 1 }} – {{ Math.min(sectionBPageIndex*itemsPerPage, sbFiltered.length) }} of {{ sbFiltered.length }}
|
||||||
|
</small>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" :disabled="sectionBPageIndex===1" @@click="sectionBPageIndex--">Prev</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" :disabled="sectionBPageIndex*itemsPerPage>=sbFiltered.length" @@click="sectionBPageIndex++">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const app = Vue.createApp({
|
||||||
|
data() {
|
||||||
|
const now = new Date();
|
||||||
|
return {
|
||||||
|
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
|
||||||
|
years: Array.from({ length: 10 }, (_, i) => now.getFullYear() - 5 + i),
|
||||||
|
selectedMonth: now.getMonth() + 1,
|
||||||
|
selectedYear: now.getFullYear(),
|
||||||
|
|
||||||
|
// Table 1 (Approvals)
|
||||||
|
itStatusList: [],
|
||||||
|
activeTab: 'pending',
|
||||||
|
currentPage: 1,
|
||||||
|
isApprover: false,
|
||||||
|
approverChecked: false,
|
||||||
|
|
||||||
|
// Table 2 (Section B)
|
||||||
|
sectionBList: [],
|
||||||
|
activeSbTab: 'draft',
|
||||||
|
sectionBPageIndex: 1,
|
||||||
|
isItMember: false,
|
||||||
|
sbChecked: false,
|
||||||
|
|
||||||
|
// Shared
|
||||||
|
itemsPerPage: 10,
|
||||||
|
busy: false,
|
||||||
|
error: null
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
/* ======= Table 1: Approvals ======= */
|
||||||
|
filteredData() {
|
||||||
|
if (this.activeTab === 'pending') {
|
||||||
|
// show only items the current approver can act on now
|
||||||
|
return this.itStatusList.filter(r => r.canApprove === true);
|
||||||
|
}
|
||||||
|
// completed: only those where this approver already decided
|
||||||
|
return this.itStatusList.filter(r => ['Approved', 'Rejected'].includes(r.currentUserStatus));
|
||||||
|
},
|
||||||
|
|
||||||
|
paginatedData() {
|
||||||
|
const start = (this.currentPage - 1) * this.itemsPerPage;
|
||||||
|
return this.filteredData.slice(start, start + this.itemsPerPage);
|
||||||
|
},
|
||||||
|
pendingActionsCount() { return this.itStatusList.filter(r => r.canApprove).length; },
|
||||||
|
completedActionsCount() { return this.itStatusList.filter(r => ['Approved', 'Rejected'].includes(r.currentUserStatus)).length; },
|
||||||
|
|
||||||
|
/* ======= Table 2: Section B ======= */
|
||||||
|
sbFiltered() {
|
||||||
|
const map = { draft: 'DRAFT', pending: 'PENDING', awaiting: 'AWAITING', complete: 'COMPLETE'};
|
||||||
|
const want = map[this.activeSbTab];
|
||||||
|
return this.sectionBList.filter(x => x.stage === want);
|
||||||
|
},
|
||||||
|
sbPaginated() {
|
||||||
|
const start = (this.sectionBPageIndex - 1) * this.itemsPerPage;
|
||||||
|
return this.sbFiltered.slice(start, start + this.itemsPerPage);
|
||||||
|
},
|
||||||
|
sbCountDraft() { return this.sectionBList.filter(x => x.stage === 'DRAFT').length; },
|
||||||
|
sbCountPending() { return this.sectionBList.filter(x => x.stage === 'PENDING').length; },
|
||||||
|
sbCountAwaiting() { return this.sectionBList.filter(x => x.stage === 'AWAITING').length; },
|
||||||
|
sbCountComplete() { return this.sectionBList.filter(x => x.stage === 'COMPLETE').length; },
|
||||||
|
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
/* ======= Shared helpers ======= */
|
||||||
|
formatDate(str) { if (!str) return ''; const d = new Date(str); return isNaN(d) ? str : d.toLocaleDateString(); },
|
||||||
|
getStatusBadgeClass(status) {
|
||||||
|
switch ((status || '').toLowerCase()) {
|
||||||
|
case 'approved': return 'badge bg-success';
|
||||||
|
case 'rejected': return 'badge bg-danger';
|
||||||
|
case 'pending': return 'badge bg-warning text-dark';
|
||||||
|
default: return 'badge bg-secondary';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stageBadge(stage) {
|
||||||
|
switch (stage) {
|
||||||
|
case 'COMPLETE': return { text: 'Complete', cls: 'bg-success' };
|
||||||
|
case 'PENDING': return { text: 'Pending', cls: 'bg-secondary' };
|
||||||
|
case 'DRAFT': return { text: 'Draft', cls: 'bg-info text-dark' };
|
||||||
|
case 'AWAITING': return { text: 'Awaiting Acceptances', cls: 'bg-warning text-dark' };
|
||||||
|
case 'NOT_ELIGIBLE': return { text: 'Not Eligible', cls: 'bg-dark' };
|
||||||
|
default: return { text: stage, cls: 'bg-secondary' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
/* ======= Filters / Tabs ======= */
|
||||||
|
onPeriodChange() {
|
||||||
|
// Reload both; each loader sets its own access flags
|
||||||
|
this.loadApprovals();
|
||||||
|
this.loadSectionB();
|
||||||
|
},
|
||||||
|
switchTab(tab) {
|
||||||
|
this.activeTab = tab;
|
||||||
|
this.currentPage = 1;
|
||||||
|
},
|
||||||
|
switchSbTab(tab) {
|
||||||
|
this.activeSbTab = tab;
|
||||||
|
this.sectionBPageIndex = 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ======= Data loaders ======= */
|
||||||
|
async loadApprovals() {
|
||||||
|
try {
|
||||||
|
this.error = null;
|
||||||
|
const r = await fetch(`/ItRequestAPI/pending?month=${this.selectedMonth}&year=${this.selectedYear}`);
|
||||||
|
if (!r.ok) throw new Error(`Load failed (${r.status})`);
|
||||||
|
const j = await r.json();
|
||||||
|
const roles = (j && (j.roles || j.Roles)) || [];
|
||||||
|
this.isApprover = Array.isArray(roles) && roles.length > 0;
|
||||||
|
this.approverChecked = true;
|
||||||
|
this.itStatusList = (j && (j.data || j.Data)) || [];
|
||||||
|
this.currentPage = 1;
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message || 'Failed to load approvals.';
|
||||||
|
this.isApprover = false;
|
||||||
|
this.approverChecked = true;
|
||||||
|
this.itStatusList = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadSectionB() {
|
||||||
|
try {
|
||||||
|
this.error = null;
|
||||||
|
const r = await fetch(`/ItRequestAPI/sectionB/approvedList?month=${this.selectedMonth}&year=${this.selectedYear}`);
|
||||||
|
if (!r.ok) throw new Error(`Section B list failed (${r.status})`);
|
||||||
|
const j = await r.json();
|
||||||
|
this.isItMember = !!(j && j.isItMember);
|
||||||
|
this.sbChecked = true;
|
||||||
|
this.sectionBList = (j && (j.data || j.Data)) || [];
|
||||||
|
this.sectionBPageIndex = 1;
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message || 'Failed to load Section B list.';
|
||||||
|
this.isItMember = false;
|
||||||
|
this.sbChecked = true;
|
||||||
|
this.sectionBList = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/* ======= Actions ======= */
|
||||||
|
async updateStatus(statusId, decision) {
|
||||||
|
try {
|
||||||
|
if (this.busy) return;
|
||||||
|
this.busy = true; this.error = null;
|
||||||
|
let comment = null;
|
||||||
|
if (decision === 'Rejected') { const input = prompt('Optional rejection comment:'); comment = input?.trim() || null; }
|
||||||
|
const res = await fetch(`/ItRequestAPI/approveReject`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ statusId, decision, comment })
|
||||||
|
});
|
||||||
|
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.message || `Failed (${res.status})`); }
|
||||||
|
await this.loadApprovals();
|
||||||
|
await this.loadSectionB();
|
||||||
|
} catch (e) { this.error = e.message || 'Something went wrong.'; }
|
||||||
|
finally { this.busy = false; }
|
||||||
|
},
|
||||||
|
async acceptIt(statusId) {
|
||||||
|
try {
|
||||||
|
if (this.busy) return;
|
||||||
|
this.busy = true; this.error = null;
|
||||||
|
const res = await fetch(`/ItRequestAPI/sectionB/accept`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ statusId, by: 'IT' })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.message || `IT accept failed (${res.status})`);
|
||||||
|
}
|
||||||
|
await this.loadSectionB();
|
||||||
|
} catch (e) { this.error = e.message || 'Failed to accept as IT.'; }
|
||||||
|
finally { this.busy = false; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async acceptRequestor(statusId) {
|
||||||
|
try {
|
||||||
|
if (this.busy) return;
|
||||||
|
this.busy = true; this.error = null;
|
||||||
|
const res = await fetch(`/ItRequestAPI/sectionB/accept`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ statusId, by: 'REQUESTOR' })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j.message || `Accept failed (${res.status})`);
|
||||||
|
}
|
||||||
|
await this.loadSectionB();
|
||||||
|
} catch (e) { this.error = e.message || 'Failed to accept as Requestor.'; }
|
||||||
|
finally { this.busy = false; }
|
||||||
|
},
|
||||||
|
viewRequest(statusId) { window.location.href = `/IT/ApprovalDashboard/RequestReview?statusId=${statusId}`; },
|
||||||
|
openSectionB(statusId) {
|
||||||
|
const here = window.location.pathname + window.location.search + window.location.hash;
|
||||||
|
const returnUrl = encodeURIComponent(here);
|
||||||
|
window.location.href = `/IT/ApprovalDashboard/SectionB?statusId=${statusId}&returnUrl=${returnUrl}`;
|
||||||
|
},
|
||||||
|
openSectionBEdit(statusId) {
|
||||||
|
const here = window.location.pathname + window.location.search + window.location.hash;
|
||||||
|
const returnUrl = encodeURIComponent(here);
|
||||||
|
window.location.href = `/IT/ApprovalDashboard/SectionBEdit?statusId=${statusId}&returnUrl=${returnUrl}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadPdf(statusId) { window.open(`/ItRequestAPI/sectionB/pdf?statusId=${statusId}`, '_blank'); }
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
// We need to call both to determine access flags.
|
||||||
|
this.loadApprovals();
|
||||||
|
this.loadSectionB();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.mount('#app');
|
||||||
|
</script>
|
||||||
825
Areas/IT/Views/ApprovalDashboard/Create.cshtml
Normal file
825
Areas/IT/Views/ApprovalDashboard/Create.cshtml
Normal file
@ -0,0 +1,825 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "New IT Request";
|
||||||
|
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||||
|
}
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--card-r: 16px;
|
||||||
|
--soft-b: #eef2f6;
|
||||||
|
--soft-s: 0 8px 24px rgba(0,0,0,.08);
|
||||||
|
--muted: #6b7280;
|
||||||
|
--ok: #16a34a;
|
||||||
|
--warn: #f59e0b;
|
||||||
|
--err: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
#itFormApp {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 16px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--soft-b);
|
||||||
|
border-radius: var(--card-r);
|
||||||
|
box-shadow: var(--soft-s);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--soft-b);
|
||||||
|
background: linear-gradient(180deg,#fbfdff,#f7fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-head h6 {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0b5ed7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-body {
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .45rem;
|
||||||
|
padding: .25rem .6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid rgba(0,0,0,.06);
|
||||||
|
background: #eef2f7;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip i {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-ok {
|
||||||
|
background: #e7f7ed;
|
||||||
|
color: #166534;
|
||||||
|
border-color: #c7ecd3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-warn {
|
||||||
|
background: #fff7e6;
|
||||||
|
color: #92400e;
|
||||||
|
border-color: #fdebd1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label .req {
|
||||||
|
color: var(--err);
|
||||||
|
margin-left: .2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalid-hint {
|
||||||
|
color: var(--err);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.req-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2,1fr);
|
||||||
|
gap: 12px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (max-width:768px) {
|
||||||
|
.req-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-table {
|
||||||
|
border: 1px solid var(--soft-b);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-head {
|
||||||
|
background: #f3f6fb;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-row:hover {
|
||||||
|
background: #fafcff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-soft {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: .5rem .8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
background: #0b5ed7;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add:hover {
|
||||||
|
background: #0a53be;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-del {
|
||||||
|
background: #fff;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #f1d2d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-del:hover {
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-checks .form-check {
|
||||||
|
margin-bottom: .4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-bar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255,255,255,.85);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
border: 1px solid var(--soft-b);
|
||||||
|
box-shadow: var(--soft-s);
|
||||||
|
margin: 6px 0 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-go {
|
||||||
|
background: #22c55e;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: .6rem .95rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-go:hover {
|
||||||
|
background: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send {
|
||||||
|
background: #0b5ed7;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: .6rem .95rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send:hover {
|
||||||
|
background: #0a53be;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset {
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
border: 1px solid var(--soft-b);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: .6rem .95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-flags {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #eef2f6;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="itFormApp">
|
||||||
|
<div class="page-head">
|
||||||
|
<h3 class="page-title"></h3>
|
||||||
|
<div class="subtle">Stages: HOD → Group IT HOD → Finance HOD → Management</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Requester -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-head">
|
||||||
|
<h6><i class="bi bi-person-badge"></i> Requester Details</h6>
|
||||||
|
<span class="note">These fields are snapshotted at submission</span>
|
||||||
|
</div>
|
||||||
|
<div class="ui-body">
|
||||||
|
<div class="req-grid">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Staff Name</label>
|
||||||
|
<input type="text" class="form-control" v-model.trim="model.staffName" readonly>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Designation</label>
|
||||||
|
<input type="text" class="form-control" v-model.trim="model.designation" readonly>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Company</label>
|
||||||
|
<input type="text" class="form-control" v-model.trim="model.companyName" readonly>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Div/Dept</label>
|
||||||
|
<input type="text" class="form-control" v-model.trim="model.departmentName" readonly>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Location</label>
|
||||||
|
<input type="text" class="form-control" v-model.trim="model.location" readonly>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Phone Ext</label>
|
||||||
|
<input type="text" class="form-control" v-model.trim="model.phoneExt" readonly>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Employment Status</label>
|
||||||
|
<select class="form-select" v-model="model.employmentStatus" disabled>
|
||||||
|
<option value="">--</option>
|
||||||
|
<option>Permanent</option>
|
||||||
|
<option>Contract</option>
|
||||||
|
<option>Temp</option>
|
||||||
|
<option>New Staff</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="model.employmentStatus==='Contract' || model.employmentStatus==='Temp'">
|
||||||
|
<label class="form-label">Contract End Date</label>
|
||||||
|
<input type="date" class="form-control" v-model="model.contractEndDate" readonly>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Required Date <span class="req">*</span></label>
|
||||||
|
<input type="date" class="form-control" v-model="model.requiredDate" :min="minReqISO">
|
||||||
|
<div class="invalid-hint" v-if="validation.requiredDate">{{ validation.requiredDate }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hardware -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-head">
|
||||||
|
<h6><i class="bi bi-cpu"></i> Hardware Requirements</h6>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="chip" v-if="hardwareCount===0"><i class="bi bi-inboxes"></i>None selected</span>
|
||||||
|
<span class="chip chip-ok" v-else><i class="bi bi-check2"></i>{{ hardwareCount }} selected</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Purpose <span class="req" v-if="hardwareCount>0">*</span></label>
|
||||||
|
<select class="form-select" v-model="hardwarePurpose">
|
||||||
|
<option value="">-- Select --</option>
|
||||||
|
<option value="NewRecruitment">New Staff Recruitment</option>
|
||||||
|
<option value="Replacement">Replacement</option>
|
||||||
|
<option value="Additional">Additional</option>
|
||||||
|
</select>
|
||||||
|
<div class="invalid-hint" v-if="validation.hardwarePurpose">{{ validation.hardwarePurpose }}</div>
|
||||||
|
|
||||||
|
<label class="form-label mt-3">Justification (for hardware change)</label>
|
||||||
|
<textarea class="form-control" rows="3" v-model="hardwareJustification" placeholder="-"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<label class="form-label">Select below</label>
|
||||||
|
<div class="small text-muted">All-inclusive toggles will auto-select sensible accessories</div>
|
||||||
|
</div>
|
||||||
|
<div class="stacked-checks">
|
||||||
|
<div class="form-check" v-for="opt in hardwareCategories" :key="opt.key">
|
||||||
|
<input class="form-check-input" type="checkbox" :id="'cat_'+opt.key"
|
||||||
|
v-model="opt.include" @@change="onHardwareToggle(opt.key)">
|
||||||
|
<label class="form-check-label" :for="'cat_'+opt.key">{{ opt.label }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="form-label">Other (Specify)</label>
|
||||||
|
<input class="form-control form-control-sm" v-model.trim="hardwareOther" placeholder="-">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-head">
|
||||||
|
<h6><i class="bi bi-envelope-paper"></i> Email</h6>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="note">Enter proposed address(es) without <code>@@domain</code></span>
|
||||||
|
<button class="btn-soft btn-add" @@click="addEmail"><i class="bi bi-plus"></i> Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui-body">
|
||||||
|
<div class="mini-table">
|
||||||
|
<div class="mini-head">Proposed Address (without @@domain)</div>
|
||||||
|
<div v-for="(row, i) in emailRows" :key="'em-'+i" class="mini-row">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<input class="form-control form-control-sm" v-model.trim="row.proposedAddress" placeholder="e.g. j.doe">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-del btn-sm" @@click="removeEmail(i)"><i class="bi bi-x"></i></button>
|
||||||
|
</div>
|
||||||
|
<div v-if="emailRows.length===0" class="mini-row">
|
||||||
|
<div class="text-muted">No email rows</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OS -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-head">
|
||||||
|
<h6><i class="bi bi-windows"></i> Operating System Requirements</h6>
|
||||||
|
<button class="btn-soft btn-add" @@click="addOs"><i class="bi bi-plus"></i> Add</button>
|
||||||
|
</div>
|
||||||
|
<div class="ui-body">
|
||||||
|
<div class="mini-table">
|
||||||
|
<div class="mini-head">Requirement</div>
|
||||||
|
<div v-for="(row, i) in osReqs" :key="'os-'+i" class="mini-row">
|
||||||
|
<textarea class="form-control" rows="2" v-model="row.requirementText" placeholder="e.g. Windows 11 Pro required due to ..."></textarea>
|
||||||
|
<button class="btn btn-del btn-sm" @@click="removeOs(i)"><i class="bi bi-x"></i></button>
|
||||||
|
</div>
|
||||||
|
<div v-if="osReqs.length===0" class="mini-row">
|
||||||
|
<div class="text-muted">No OS requirements</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Software -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-head">
|
||||||
|
<h6><i class="bi bi-boxes"></i> Software</h6>
|
||||||
|
<span class="note">Tick to include; use Others to specify anything not listed</span>
|
||||||
|
</div>
|
||||||
|
<div class="ui-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<h6 class="mb-2">General Software</h6>
|
||||||
|
<div class="form-check" v-for="opt in softwareGeneralOpts" :key="'gen-'+opt">
|
||||||
|
<input class="form-check-input" type="checkbox" :id="'gen_'+opt" v-model="softwareGeneral[opt]">
|
||||||
|
<label class="form-check-label" :for="'gen_'+opt">{{ opt }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="form-label">Others (Specify)</label>
|
||||||
|
<input class="form-control form-control-sm" v-model.trim="softwareGeneralOther" placeholder="-">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<h6 class="mb-2">Utility Software</h6>
|
||||||
|
<div class="form-check" v-for="opt in softwareUtilityOpts" :key="'utl-'+opt">
|
||||||
|
<input class="form-check-input" type="checkbox" :id="'utl_'+opt" v-model="softwareUtility[opt]">
|
||||||
|
<label class="form-check-label" :for="'utl_'+opt">{{ opt }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="form-label">Others (Specify)</label>
|
||||||
|
<input class="form-control form-control-sm" v-model.trim="softwareUtilityOther" placeholder="-">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<h6 class="mb-2">Custom Software</h6>
|
||||||
|
<label class="form-label">Others (Specify)</label>
|
||||||
|
<input class="form-control form-control-sm" v-model.trim="softwareCustomOther" placeholder="-">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shared Permissions (CAP 6) -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-head">
|
||||||
|
<h6><i class="bi bi-share"></i> Shared Permissions</h6>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="note">Max 6 entries</span>
|
||||||
|
<button class="btn-soft btn-add" @@click="addPerm" :disabled="sharedPerms.length>=6"><i class="bi bi-plus"></i> Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui-body">
|
||||||
|
<div class="mini-table">
|
||||||
|
<div class="mini-head">Share Name & Permissions</div>
|
||||||
|
<div v-for="(p, i) in sharedPerms" :key="'sp-'+i" class="mini-row">
|
||||||
|
<input class="form-control form-control-sm" style="max-width:280px"
|
||||||
|
v-model.trim="p.shareName" placeholder="e.g. Finance Shared Folder">
|
||||||
|
<div class="perm-flags">
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" v-model="p.canRead" :id="'r'+i">
|
||||||
|
<label class="form-check-label" :for="'r'+i">Read</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" v-model="p.canWrite" :id="'w'+i">
|
||||||
|
<label class="form-check-label" :for="'w'+i">Write</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" v-model="p.canDelete" :id="'d'+i">
|
||||||
|
<label class="form-check-label" :for="'d'+i">Delete</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" v-model="p.canRemove" :id="'u'+i">
|
||||||
|
<label class="form-check-label" :for="'u'+i">Remove</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-del btn-sm" @@click="removePerm(i)"><i class="bi bi-x"></i></button>
|
||||||
|
</div>
|
||||||
|
<div v-if="sharedPerms.length===0" class="mini-row">
|
||||||
|
<div class="text-muted">No shared permissions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="invalid-hint" v-if="validation.sharedPerms">{{ validation.sharedPerms }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit bar -->
|
||||||
|
<div class="submit-bar">
|
||||||
|
<div class="me-auto d-flex align-items-center gap-2">
|
||||||
|
<span class="chip" :class="model.requiredDate ? 'chip-ok' : 'chip-warn'">
|
||||||
|
<i class="bi" :class="model.requiredDate ? 'bi-check2' : 'bi-exclamation-triangle'"></i>
|
||||||
|
{{ model.requiredDate ? 'Required date set' : 'Required date missing' }}
|
||||||
|
</span>
|
||||||
|
<span class="chip" v-if="hardwareCount>0 && !hardwarePurpose"><i class="bi bi-exclamation-triangle"></i> Hardware purpose required</span>
|
||||||
|
<span class="chip chip-ok" v-else-if="hardwareCount>0"><i class="bi bi-check2"></i> Hardware purpose ok</span>
|
||||||
|
<span class="chip" v-if="sharedPerms.length>6"><i class="bi bi-exclamation-triangle"></i> Max 6 permissions</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-reset" @@click="resetForm" :disabled="saving">Reset (sections)</button>
|
||||||
|
<button class="btn btn-go" @@click="saveDraft" :disabled="saving">
|
||||||
|
<span v-if="saving && intent==='draft'" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Save Draft
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-send" @@click="openConfirm" :disabled="saving">
|
||||||
|
<span v-if="saving && intent==='send'" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Send Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="sendConfirm" tabindex="-1" aria-labelledby="sendConfirmLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content" style="border-radius:14px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h6 class="modal-title fw-bold" id="sendConfirmLabel">Submit & Lock</h6>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
Please double-check your entries. Once sent, this request becomes <strong>Pending</strong> and is <strong>locked</strong> from editing.
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="confirmSendBtn">Yes, Send Now</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
async function ensureBootstrapModal() {
|
||||||
|
if (window.bootstrap && window.bootstrap.Modal) return;
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js";
|
||||||
|
s.async = true;
|
||||||
|
s.onload = resolve;
|
||||||
|
s.onerror = resolve;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const EDIT_WINDOW_HOURS = 24;
|
||||||
|
|
||||||
|
const app = Vue.createApp({
|
||||||
|
data() {
|
||||||
|
const plus7 = new Date(); plus7.setDate(plus7.getDate() + 7);
|
||||||
|
return {
|
||||||
|
saving: false,
|
||||||
|
intent: '',
|
||||||
|
validation: { requiredDate: "", hardwarePurpose: "", sharedPerms: "" },
|
||||||
|
minReqISO: plus7.toISOString().slice(0, 10),
|
||||||
|
|
||||||
|
model: {
|
||||||
|
userId: 0, staffName: "", companyName: "", departmentName: "",
|
||||||
|
designation: "", location: "", employmentStatus: "", contractEndDate: null,
|
||||||
|
requiredDate: "", phoneExt: ""
|
||||||
|
},
|
||||||
|
|
||||||
|
hardwarePurpose: "",
|
||||||
|
hardwareJustification: "",
|
||||||
|
hardwareCategories: [
|
||||||
|
{ key: "DesktopAllIn", label: "Desktop (all inclusive)", include: false },
|
||||||
|
{ key: "NotebookAllIn", label: "Notebook (all inclusive)", include: false },
|
||||||
|
{ key: "DesktopOnly", label: "Desktop only", include: false },
|
||||||
|
{ key: "NotebookOnly", label: "Notebook only", include: false },
|
||||||
|
{ key: "NotebookBattery", label: "Notebook battery", include: false },
|
||||||
|
{ key: "PowerAdapter", label: "Power Adapter", include: false },
|
||||||
|
{ key: "Mouse", label: "Computer Mouse", include: false },
|
||||||
|
{ key: "ExternalHDD", label: "External Hard Drive", include: false }
|
||||||
|
],
|
||||||
|
hardwareOther: "",
|
||||||
|
|
||||||
|
emailRows: [],
|
||||||
|
osReqs: [],
|
||||||
|
|
||||||
|
softwareGeneralOpts: ["MS Word", "MS Excel", "MS Outlook", "MS PowerPoint", "MS Access", "MS Project", "Acrobat Standard", "AutoCAD", "Worktop/ERP Login"],
|
||||||
|
softwareUtilityOpts: ["PDF Viewer", "7Zip", "AutoCAD Viewer", "Smart Draw"],
|
||||||
|
softwareGeneral: {},
|
||||||
|
softwareUtility: {},
|
||||||
|
softwareGeneralOther: "",
|
||||||
|
softwareUtilityOther: "",
|
||||||
|
softwareCustomOther: "",
|
||||||
|
|
||||||
|
// shared permissions
|
||||||
|
sharedPerms: []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
hardwareCount() {
|
||||||
|
let c = this.hardwareCategories.filter(x => x.include).length;
|
||||||
|
if (this.hardwareOther.trim()) c += 1;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// ----- Hardware helpers -----
|
||||||
|
onHardwareToggle(key) {
|
||||||
|
const set = (k, v) => {
|
||||||
|
const t = this.hardwareCategories.find(x => x.key === k);
|
||||||
|
if (t) t.include = v;
|
||||||
|
};
|
||||||
|
if (key === "DesktopAllIn") {
|
||||||
|
const allIn = this.hardwareCategories.find(x => x.key === "DesktopAllIn")?.include;
|
||||||
|
if (allIn) {
|
||||||
|
// mutually exclusive with NotebookOnly/NotebookAllIn
|
||||||
|
set("NotebookAllIn", false);
|
||||||
|
set("NotebookOnly", false);
|
||||||
|
// sensible accessories
|
||||||
|
set("Mouse", true);
|
||||||
|
// desktop doesn't need PowerAdapter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key === "NotebookAllIn") {
|
||||||
|
const allIn = this.hardwareCategories.find(x => x.key === "NotebookAllIn")?.include;
|
||||||
|
if (allIn) {
|
||||||
|
set("DesktopAllIn", false);
|
||||||
|
set("DesktopOnly", false);
|
||||||
|
// sensible accessories
|
||||||
|
set("PowerAdapter", true);
|
||||||
|
set("Mouse", true);
|
||||||
|
set("NotebookBattery", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key === "DesktopOnly") {
|
||||||
|
const only = this.hardwareCategories.find(x => x.key === "DesktopOnly")?.include;
|
||||||
|
if (only) {
|
||||||
|
set("DesktopAllIn", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (key === "NotebookOnly") {
|
||||||
|
const only = this.hardwareCategories.find(x => x.key === "NotebookOnly")?.include;
|
||||||
|
if (only) {
|
||||||
|
set("NotebookAllIn", false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----- Email/OS -----
|
||||||
|
addEmail() { this.emailRows.push({ proposedAddress: "" }); },
|
||||||
|
removeEmail(i) { this.emailRows.splice(i, 1); },
|
||||||
|
addOs() { this.osReqs.push({ requirementText: "" }); },
|
||||||
|
removeOs(i) { this.osReqs.splice(i, 1); },
|
||||||
|
|
||||||
|
// ----- Shared perms -----
|
||||||
|
addPerm() {
|
||||||
|
if (this.sharedPerms.length >= 6) return;
|
||||||
|
this.sharedPerms.push({ shareName: "", canRead: true, canWrite: false, canDelete: false, canRemove: false });
|
||||||
|
},
|
||||||
|
removePerm(i) { this.sharedPerms.splice(i, 1); },
|
||||||
|
|
||||||
|
// ----- Validation -----
|
||||||
|
validate() {
|
||||||
|
this.validation = { requiredDate: "", hardwarePurpose: "", sharedPerms: "" };
|
||||||
|
|
||||||
|
if (!this.model.requiredDate) {
|
||||||
|
this.validation.requiredDate = "Required Date is mandatory.";
|
||||||
|
} else if (this.model.requiredDate < this.minReqISO) {
|
||||||
|
this.validation.requiredDate = "Required Date must be at least 7 days from today.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const anyHardware = this.hardwareCount > 0;
|
||||||
|
if (anyHardware && !this.hardwarePurpose) this.validation.hardwarePurpose = "Please select a Hardware Purpose.";
|
||||||
|
|
||||||
|
if (this.sharedPerms.length > 6) this.validation.sharedPerms = "Maximum 6 shared permissions.";
|
||||||
|
|
||||||
|
return !this.validation.requiredDate && !this.validation.hardwarePurpose && !this.validation.sharedPerms;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ----- DTO -----
|
||||||
|
buildDto() {
|
||||||
|
const hardware = [];
|
||||||
|
const justification = this.hardwareJustification || "";
|
||||||
|
const purpose = this.hardwarePurpose || "";
|
||||||
|
this.hardwareCategories.forEach(c => {
|
||||||
|
if (c.include) hardware.push({ category: c.key, purpose, justification, otherDescription: "" });
|
||||||
|
});
|
||||||
|
if (this.hardwareOther.trim()) {
|
||||||
|
hardware.push({ category: "Other", purpose, justification, otherDescription: this.hardwareOther.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
const emails = this.emailRows.map(x => ({ proposedAddress: x.proposedAddress || "" }));
|
||||||
|
const OSReqs = this.osReqs.map(x => ({ requirementText: x.requirementText }));
|
||||||
|
|
||||||
|
const software = [];
|
||||||
|
Object.keys(this.softwareGeneral).forEach(name => { if (this.softwareGeneral[name]) software.push({ bucket: "General", name, otherName: "", notes: "" }); });
|
||||||
|
Object.keys(this.softwareUtility).forEach(name => { if (this.softwareUtility[name]) software.push({ bucket: "Utility", name, otherName: "", notes: "" }); });
|
||||||
|
if (this.softwareGeneralOther?.trim()) software.push({ bucket: "General", name: "Others", otherName: this.softwareGeneralOther.trim(), notes: "" });
|
||||||
|
if (this.softwareUtilityOther?.trim()) software.push({ bucket: "Utility", name: "Others", otherName: this.softwareUtilityOther.trim(), notes: "" });
|
||||||
|
if (this.softwareCustomOther?.trim()) software.push({ bucket: "Custom", name: "Others", otherName: this.softwareCustomOther.trim(), notes: "" });
|
||||||
|
|
||||||
|
// shared perms (cap at 6 client-side)
|
||||||
|
const sharedPerms = this.sharedPerms.slice(0, 6).map(x => ({
|
||||||
|
shareName: x.shareName || "",
|
||||||
|
canRead: !!x.canRead,
|
||||||
|
canWrite: !!x.canWrite,
|
||||||
|
canDelete: !!x.canDelete,
|
||||||
|
canRemove: !!x.canRemove
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
staffName: this.model.staffName,
|
||||||
|
companyName: this.model.companyName,
|
||||||
|
departmentName: this.model.departmentName,
|
||||||
|
designation: this.model.designation,
|
||||||
|
location: this.model.location,
|
||||||
|
employmentStatus: this.model.employmentStatus,
|
||||||
|
contractEndDate: this.model.contractEndDate || null,
|
||||||
|
requiredDate: this.model.requiredDate,
|
||||||
|
phoneExt: this.model.phoneExt,
|
||||||
|
editWindowHours: EDIT_WINDOW_HOURS,
|
||||||
|
hardware, emails, OSReqs, software, sharedPerms
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async createRequest(sendNow = false) {
|
||||||
|
const dto = this.buildDto();
|
||||||
|
dto.sendNow = !!sendNow;
|
||||||
|
const r = await fetch('/ItRequestAPI/create', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(dto)
|
||||||
|
});
|
||||||
|
const ct = r.headers.get('content-type') || '';
|
||||||
|
const payload = ct.includes('application/json') ? await r.json() : { message: await r.text() };
|
||||||
|
if (!r.ok) throw new Error(payload?.message || `Create failed (${r.status})`);
|
||||||
|
const statusId = payload?.statusId;
|
||||||
|
if (!statusId) throw new Error('Create succeeded but no statusId returned.');
|
||||||
|
return statusId;
|
||||||
|
},
|
||||||
|
|
||||||
|
async saveDraft() {
|
||||||
|
if (!this.validate()) return;
|
||||||
|
this.saving = true; this.intent = 'draft';
|
||||||
|
try {
|
||||||
|
await this.createRequest(false);
|
||||||
|
window.location.href = `/IT/ApprovalDashboard/MyRequests`;
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + (e?.message || e));
|
||||||
|
} finally { this.saving = false; this.intent = ''; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async openConfirm() {
|
||||||
|
if (!this.validate()) return;
|
||||||
|
await ensureBootstrapModal();
|
||||||
|
const modalEl = document.getElementById('sendConfirm');
|
||||||
|
if (!window.bootstrap || !bootstrap.Modal) {
|
||||||
|
if (confirm('Submit & Lock?\nOnce sent, this request becomes Pending and is locked from editing.')) {
|
||||||
|
this.sendNow();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const Modal = bootstrap.Modal;
|
||||||
|
const inst = (typeof Modal.getOrCreateInstance === 'function')
|
||||||
|
? Modal.getOrCreateInstance(modalEl)
|
||||||
|
: (Modal.getInstance(modalEl) || new Modal(modalEl));
|
||||||
|
const btn = document.getElementById('confirmSendBtn');
|
||||||
|
btn.onclick = () => { inst.hide(); this.sendNow(); };
|
||||||
|
inst.show();
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendNow() {
|
||||||
|
if (!this.validate()) return;
|
||||||
|
this.saving = true; this.intent = 'send';
|
||||||
|
try {
|
||||||
|
await this.createRequest(true);
|
||||||
|
window.location.href = `/IT/ApprovalDashboard/MyRequests`;
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + (e?.message || e));
|
||||||
|
} finally {
|
||||||
|
this.saving = false; this.intent = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resetForm() {
|
||||||
|
this.hardwarePurpose = "";
|
||||||
|
this.hardwareJustification = "";
|
||||||
|
this.hardwareCategories.forEach(x => x.include = false);
|
||||||
|
this.hardwareOther = "";
|
||||||
|
this.emailRows = [];
|
||||||
|
this.osReqs = [];
|
||||||
|
this.softwareGeneral = {};
|
||||||
|
this.softwareUtility = {};
|
||||||
|
this.softwareGeneralOther = "";
|
||||||
|
this.softwareUtilityOther = "";
|
||||||
|
this.softwareCustomOther = "";
|
||||||
|
this.sharedPerms = [];
|
||||||
|
this.model.requiredDate = "";
|
||||||
|
this.validation = { requiredDate: "", hardwarePurpose: "", sharedPerms: "" };
|
||||||
|
},
|
||||||
|
|
||||||
|
async prefillFromServer() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/ItRequestAPI/me');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const me = await res.json();
|
||||||
|
this.model.userId = me.userId || 0;
|
||||||
|
this.model.staffName = me.staffName || "";
|
||||||
|
this.model.companyName = me.companyName || "";
|
||||||
|
this.model.departmentName = me.departmentName || "";
|
||||||
|
this.model.designation = me.designation || "";
|
||||||
|
this.model.location = me.location || "";
|
||||||
|
this.model.employmentStatus = me.employmentStatus || "";
|
||||||
|
this.model.contractEndDate = me.contractEndDate || null;
|
||||||
|
this.model.phoneExt = me.phoneExt || "";
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() { this.prefillFromServer(); }
|
||||||
|
});
|
||||||
|
app.mount('#itFormApp');
|
||||||
|
</script>
|
||||||
|
}
|
||||||
698
Areas/IT/Views/ApprovalDashboard/Edit.cshtml
Normal file
698
Areas/IT/Views/ApprovalDashboard/Edit.cshtml
Normal file
@ -0,0 +1,698 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "Edit IT Request";
|
||||||
|
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||||
|
}
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--card-r: 16px;
|
||||||
|
--soft-b: #eef2f6;
|
||||||
|
--soft-s: 0 8px 24px rgba(0,0,0,.08);
|
||||||
|
--muted: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editApp {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 16px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--soft-b);
|
||||||
|
border-radius: var(--card-r);
|
||||||
|
box-shadow: var(--soft-s);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-bottom: 1px solid var(--soft-b);
|
||||||
|
background: linear-gradient(180deg,#fbfdff,#f7fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-head h6 {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0b5ed7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-body {
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-table {
|
||||||
|
border: 1px solid var(--soft-b);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-head {
|
||||||
|
background: #f3f6fb;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-row:hover {
|
||||||
|
background: #fafcff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-soft {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: .5rem .8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add {
|
||||||
|
background: #0b5ed7;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add:hover {
|
||||||
|
background: #0a53be;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-del {
|
||||||
|
background: #fff;
|
||||||
|
color: #dc2626;
|
||||||
|
border: 1px solid #f1d2d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-del:hover {
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stacked-checks .form-check {
|
||||||
|
margin-bottom: .4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-bar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255,255,255,.85);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
border: 1px solid var(--soft-b);
|
||||||
|
box-shadow: var(--soft-s);
|
||||||
|
margin: 6px 0 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-go {
|
||||||
|
background: #22c55e;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: .6rem .95rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-go:hover {
|
||||||
|
background: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset {
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
border: 1px solid var(--soft-b);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: .6rem .95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-reset:hover {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invalid-hint {
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-box {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,.05);
|
||||||
|
min-width: 80px;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background .3s ease, color .3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-active {
|
||||||
|
background: #ecfdf5;
|
||||||
|
color: #166534;
|
||||||
|
border-color: #bbf7d0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown-expired {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: #991b1b;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-flags {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="editApp">
|
||||||
|
<div class="page-head">
|
||||||
|
<div class="subtle d-flex align-items-center gap-2">
|
||||||
|
<span class="badge" :class="isEditable ? 'bg-success' : 'bg-secondary'">{{ isEditable ? 'Editable' : 'Locked' }}</span>
|
||||||
|
<div class="countdown-box" :class="isEditable ? 'countdown-active' : 'countdown-expired'">
|
||||||
|
<i class="bi bi-clock-history me-1"></i>
|
||||||
|
<span id="countdown">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Requester -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-head">
|
||||||
|
<h6><i class="bi bi-person-badge"></i> Requester Details</h6>
|
||||||
|
<span class="note">These fields are snapshotted at submission</span>
|
||||||
|
</div>
|
||||||
|
<div class="ui-body">
|
||||||
|
<div class="req-grid">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Staff Name</label>
|
||||||
|
<input type="text" class="form-control" v-model.trim="model.staffName" readonly>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Designation</label>
|
||||||
|
<input type="text" class="form-control" v-model.trim="model.designation" :disabled="!isEditable">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Company</label>
|
||||||
|
<input type="text" class="form-control" v-model.trim="model.companyName" readonly>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Div/Dept</label>
|
||||||
|
<input type="text" class="form-control" v-model.trim="model.departmentName" readonly>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Location</label>
|
||||||
|
<input type="text" class="form-control" v-model.trim="model.location" :disabled="!isEditable">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Phone Ext</label>
|
||||||
|
<input type="text" class="form-control" v-model.trim="model.phoneExt" :disabled="!isEditable">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Employment Status</label>
|
||||||
|
<select class="form-select" v-model="model.employmentStatus" :disabled="!isEditable">
|
||||||
|
<option value="">--</option>
|
||||||
|
<option>Permanent</option>
|
||||||
|
<option>Contract</option>
|
||||||
|
<option>Temp</option>
|
||||||
|
<option>New Staff</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div v-if="model.employmentStatus==='Contract' || model.employmentStatus==='Temp'">
|
||||||
|
<label class="form-label">Contract End Date</label>
|
||||||
|
<input type="date" class="form-control" v-model="model.contractEndDate" :disabled="!isEditable">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Required Date <span class="text-danger">*</span></label>
|
||||||
|
<input type="date" class="form-control" v-model="model.requiredDate" :min="minReqISO" :disabled="!isEditable">
|
||||||
|
<div class="invalid-hint" v-if="validation.requiredDate">{{ validation.requiredDate }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hardware -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-head">
|
||||||
|
<h6><i class="bi bi-cpu"></i> Hardware Requirements</h6>
|
||||||
|
</div>
|
||||||
|
<div class="ui-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Purpose <span class="text-danger" v-if="hardwareCount>0">*</span></label>
|
||||||
|
<select class="form-select" v-model="hardwarePurpose" :disabled="!isEditable">
|
||||||
|
<option value="">-- Select --</option>
|
||||||
|
<option value="NewRecruitment">New Staff Recruitment</option>
|
||||||
|
<option value="Replacement">Replacement</option>
|
||||||
|
<option value="Additional">Additional</option>
|
||||||
|
</select>
|
||||||
|
<div class="invalid-hint" v-if="validation.hardwarePurpose">{{ validation.hardwarePurpose }}</div>
|
||||||
|
|
||||||
|
<label class="form-label mt-3">Justification (for hardware change)</label>
|
||||||
|
<textarea class="form-control" rows="3" v-model="hardwareJustification" :disabled="!isEditable" placeholder="-"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<label class="form-label">Select below</label>
|
||||||
|
<div class="small text-muted">All-inclusive toggles will auto-select sensible accessories</div>
|
||||||
|
</div>
|
||||||
|
<div class="stacked-checks">
|
||||||
|
<div class="form-check" v-for="opt in hardwareCategories" :key="opt.key">
|
||||||
|
<input class="form-check-input" type="checkbox" :id="'cat_'+opt.key"
|
||||||
|
v-model="opt.include" :disabled="!isEditable" @@change="onHardwareToggle(opt.key)">
|
||||||
|
<label class="form-check-label" :for="'cat_'+opt.key">{{ opt.label }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="form-label">Other (Specify)</label>
|
||||||
|
<input class="form-control form-control-sm" v-model.trim="hardwareOther" :disabled="!isEditable" placeholder="-">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-head">
|
||||||
|
<h6><i class="bi bi-envelope-paper"></i> Email</h6>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="note">Enter proposed address(es) without <code>@@domain</code></span>
|
||||||
|
<button class="btn-soft btn-add" @@click="addEmail" :disabled="!isEditable"><i class="bi bi-plus"></i> Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui-body">
|
||||||
|
<div class="mini-table">
|
||||||
|
<div class="mini-head">Proposed Address (without @@domain)</div>
|
||||||
|
<div v-for="(row, i) in emailRows" :key="'em-'+i" class="mini-row">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<input class="form-control form-control-sm" v-model.trim="row.proposedAddress" :disabled="!isEditable" placeholder="e.g. j.doe">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-del btn-sm" @@click="removeEmail(i)" :disabled="!isEditable"><i class="bi bi-x"></i></button>
|
||||||
|
</div>
|
||||||
|
<div v-if="emailRows.length===0" class="mini-row">
|
||||||
|
<div class="text-muted">No email rows</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OS -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-head">
|
||||||
|
<h6><i class="bi bi-windows"></i> Operating System Requirements</h6>
|
||||||
|
<button class="btn-soft btn-add" @@click="addOs" :disabled="!isEditable"><i class="bi bi-plus"></i> Add</button>
|
||||||
|
</div>
|
||||||
|
<div class="ui-body">
|
||||||
|
<div class="mini-table">
|
||||||
|
<div class="mini-head">Requirement</div>
|
||||||
|
<div v-for="(row, i) in osReqs" :key="'os-'+i" class="mini-row">
|
||||||
|
<textarea class="form-control" rows="2" v-model="row.requirementText" :disabled="!isEditable" placeholder="e.g. Windows 11 Pro required due to ..."></textarea>
|
||||||
|
<button class="btn btn-del btn-sm" @@click="removeOs(i)" :disabled="!isEditable"><i class="bi bi-x"></i></button>
|
||||||
|
</div>
|
||||||
|
<div v-if="osReqs.length===0" class="mini-row">
|
||||||
|
<div class="text-muted">No OS requirements</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Software -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-head">
|
||||||
|
<h6><i class="bi bi-boxes"></i> Software</h6>
|
||||||
|
<span class="note">Tick to include; use Others to specify anything not listed</span>
|
||||||
|
</div>
|
||||||
|
<div class="ui-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<h6 class="mb-2">General Software</h6>
|
||||||
|
<div class="form-check" v-for="opt in softwareGeneralOpts" :key="'gen-'+opt">
|
||||||
|
<input class="form-check-input" type="checkbox" :id="'gen_'+opt" v-model="softwareGeneral[opt]" :disabled="!isEditable">
|
||||||
|
<label class="form-check-label" :for="'gen_'+opt">{{ opt }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="form-label">Others (Specify)</label>
|
||||||
|
<input class="form-control form-control-sm" v-model.trim="softwareGeneralOther" :disabled="!isEditable" placeholder="-">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<h6 class="mb-2">Utility Software</h6>
|
||||||
|
<div class="form-check" v-for="opt in softwareUtilityOpts" :key="'utl-'+opt">
|
||||||
|
<input class="form-check-input" type="checkbox" :id="'utl_'+opt" v-model="softwareUtility[opt]" :disabled="!isEditable">
|
||||||
|
<label class="form-check-label" :for="'utl_'+opt">{{ opt }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="form-label">Others (Specify)</label>
|
||||||
|
<input class="form-control form-control-sm" v-model.trim="softwareUtilityOther" :disabled="!isEditable" placeholder="-">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<h6 class="mb-2">Custom Software</h6>
|
||||||
|
<label class="form-label">Others (Specify)</label>
|
||||||
|
<input class="form-control form-control-sm" v-model.trim="softwareCustomOther" :disabled="!isEditable" placeholder="-">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shared Permissions (CAP 6) -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-head">
|
||||||
|
<h6><i class="bi bi-share"></i> Shared Permissions</h6>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="note">Max 6 entries</span>
|
||||||
|
<button class="btn-soft btn-add" @@click="addPerm" :disabled="!isEditable || sharedPerms.length>=6"><i class="bi bi-plus"></i> Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ui-body">
|
||||||
|
<div class="mini-table">
|
||||||
|
<div class="mini-head">Share Name & Permissions</div>
|
||||||
|
<div v-for="(p, i) in sharedPerms" :key="'sp-'+i" class="mini-row">
|
||||||
|
<input class="form-control form-control-sm" style="max-width:280px"
|
||||||
|
v-model.trim="p.shareName" :disabled="!isEditable" placeholder="e.g. Finance Shared Folder">
|
||||||
|
<div class="perm-flags">
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" v-model="p.canRead" :id="'r'+i" :disabled="!isEditable">
|
||||||
|
<label class="form-check-label" :for="'r'+i">Read</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" v-model="p.canWrite" :id="'w'+i" :disabled="!isEditable">
|
||||||
|
<label class="form-check-label" :for="'w'+i">Write</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" v-model="p.canDelete" :id="'d'+i" :disabled="!isEditable">
|
||||||
|
<label class="form-check-label" :for="'d'+i">Delete</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="checkbox" v-model="p.canRemove" :id="'u'+i" :disabled="!isEditable">
|
||||||
|
<label class="form-check-label" :for="'u'+i">Remove User</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-del btn-sm" @@click="removePerm(i)" :disabled="!isEditable"><i class="bi bi-x"></i></button>
|
||||||
|
</div>
|
||||||
|
<div v-if="sharedPerms.length===0" class="mini-row">
|
||||||
|
<div class="text-muted">No shared permissions</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="invalid-hint" v-if="validation.sharedPerms">{{ validation.sharedPerms }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sticky Submit Bar -->
|
||||||
|
<div class="submit-bar">
|
||||||
|
<button class="btn btn-reset" @@click="reload" :disabled="busy">Reload</button>
|
||||||
|
<button class="btn btn-primary" @@click="save" :disabled="busy || !isEditable">
|
||||||
|
<span v-if="busy" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Save Draft
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-go" @@click="sendNow" :disabled="busy || !isEditable">Send now</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
const statusId = new URLSearchParams(location.search).get('statusId');
|
||||||
|
|
||||||
|
const app = Vue.createApp({
|
||||||
|
data() {
|
||||||
|
const plus7 = new Date(); plus7.setDate(plus7.getDate() + 7);
|
||||||
|
return {
|
||||||
|
busy: false,
|
||||||
|
isEditable: false,
|
||||||
|
remaining: 0,
|
||||||
|
minReqISO: plus7.toISOString().slice(0, 10),
|
||||||
|
validation: { requiredDate: "", hardwarePurpose: "", sharedPerms: "" },
|
||||||
|
|
||||||
|
model: {
|
||||||
|
userId: 0, staffName: "", companyName: "", departmentName: "",
|
||||||
|
designation: "", location: "", employmentStatus: "", contractEndDate: null,
|
||||||
|
requiredDate: "", phoneExt: ""
|
||||||
|
},
|
||||||
|
|
||||||
|
hardwarePurpose: "",
|
||||||
|
hardwareJustification: "",
|
||||||
|
hardwareCategories: [
|
||||||
|
{ key: "DesktopAllIn", label: "Desktop (all inclusive)", include: false },
|
||||||
|
{ key: "NotebookAllIn", label: "Notebook (all inclusive)", include: false },
|
||||||
|
{ key: "DesktopOnly", label: "Desktop only", include: false },
|
||||||
|
{ key: "NotebookOnly", label: "Notebook only", include: false },
|
||||||
|
{ key: "NotebookBattery", label: "Notebook battery", include: false },
|
||||||
|
{ key: "PowerAdapter", label: "Power Adapter", include: false },
|
||||||
|
{ key: "Mouse", label: "Computer Mouse", include: false },
|
||||||
|
{ key: "ExternalHDD", label: "External Hard Drive", include: false }
|
||||||
|
],
|
||||||
|
hardwareOther: "",
|
||||||
|
|
||||||
|
emailRows: [],
|
||||||
|
osReqs: [],
|
||||||
|
|
||||||
|
softwareGeneralOpts: ["MS Word", "MS Excel", "MS Outlook", "MS PowerPoint", "MS Access", "MS Project", "Acrobat Standard", "AutoCAD", "Worktop/ERP Login"],
|
||||||
|
softwareUtilityOpts: ["PDF Viewer", "7Zip", "AutoCAD Viewer", "Smart Draw"],
|
||||||
|
softwareGeneral: {},
|
||||||
|
softwareUtility: {},
|
||||||
|
softwareGeneralOther: "",
|
||||||
|
softwareUtilityOther: "",
|
||||||
|
softwareCustomOther: "",
|
||||||
|
|
||||||
|
// shared perms
|
||||||
|
sharedPerms: []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
hardwareCount() {
|
||||||
|
let c = this.hardwareCategories.filter(x => x.include).length;
|
||||||
|
if (this.hardwareOther.trim()) c += 1;
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// timers
|
||||||
|
startCountdown() {
|
||||||
|
const el = document.getElementById('countdown');
|
||||||
|
if (!el) return;
|
||||||
|
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
|
||||||
|
const tick = () => {
|
||||||
|
if (this.remaining <= 0) { el.textContent = '0s'; this.isEditable = false; return; }
|
||||||
|
const h = Math.floor(this.remaining / 3600);
|
||||||
|
const m = Math.floor((this.remaining % 3600) / 60);
|
||||||
|
const s = this.remaining % 60;
|
||||||
|
el.textContent = (h ? `${h}h ` : '') + (m ? `${m}m ` : (h ? '0m ' : '')) + `${s}s`;
|
||||||
|
this.remaining--; this._timer = setTimeout(tick, 1000);
|
||||||
|
}; this.$nextTick(tick);
|
||||||
|
},
|
||||||
|
startSyncRemaining() {
|
||||||
|
if (this._syncTimer) { clearInterval(this._syncTimer); this._syncTimer = null; }
|
||||||
|
const doSync = async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/ItRequestAPI/editWindow/${statusId}`);
|
||||||
|
if (!r.ok) return;
|
||||||
|
const j = await r.json();
|
||||||
|
const srvRemaining = (j && typeof j.remainingSeconds === 'number') ? j.remainingSeconds : null;
|
||||||
|
if (srvRemaining != null) { this.remaining = srvRemaining; this.isEditable = !!(j.isEditable); }
|
||||||
|
if (!this.isEditable || this.remaining <= 0) {
|
||||||
|
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
|
||||||
|
if (this._syncTimer) { clearInterval(this._syncTimer); this._syncTimer = null; }
|
||||||
|
const el = document.getElementById('countdown'); if (el) el.textContent = '0s';
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
};
|
||||||
|
doSync(); this._syncTimer = setInterval(doSync, 30000);
|
||||||
|
},
|
||||||
|
teardownTimers() { if (this._timer) clearTimeout(this._timer); if (this._syncTimer) clearInterval(this._syncTimer); },
|
||||||
|
|
||||||
|
// UI helpers
|
||||||
|
addEmail() { if (!this.isEditable) return; this.emailRows.push({ proposedAddress: "" }); },
|
||||||
|
removeEmail(i) { if (!this.isEditable) return; this.emailRows.splice(i, 1); },
|
||||||
|
addOs() { if (!this.isEditable) return; this.osReqs.push({ requirementText: "" }); },
|
||||||
|
removeOs(i) { if (!this.isEditable) return; this.osReqs.splice(i, 1); },
|
||||||
|
|
||||||
|
addPerm() { if (!this.isEditable) return; if (this.sharedPerms.length >= 6) return; this.sharedPerms.push({ shareName: "", canRead: true, canWrite: false, canDelete: false, canRemove: false }); },
|
||||||
|
removePerm(i) { if (!this.isEditable) return; this.sharedPerms.splice(i, 1); },
|
||||||
|
|
||||||
|
onHardwareToggle(key) {
|
||||||
|
if (!this.isEditable) return;
|
||||||
|
const set = (k, v) => { const t = this.hardwareCategories.find(x => x.key === k); if (t) t.include = v; };
|
||||||
|
if (key === "DesktopAllIn") {
|
||||||
|
const on = this.hardwareCategories.find(x => x.key === "DesktopAllIn")?.include;
|
||||||
|
if (on) { set("NotebookAllIn", false); set("NotebookOnly", false); set("Mouse", true); }
|
||||||
|
}
|
||||||
|
if (key === "NotebookAllIn") {
|
||||||
|
const on = this.hardwareCategories.find(x => x.key === "NotebookAllIn")?.include;
|
||||||
|
if (on) { set("DesktopAllIn", false); set("DesktopOnly", false); set("PowerAdapter", true); set("Mouse", true); set("NotebookBattery", true); }
|
||||||
|
}
|
||||||
|
if (key === "DesktopOnly") { const on = this.hardwareCategories.find(x => x.key === "DesktopOnly")?.include; if (on) { set("DesktopAllIn", false); } }
|
||||||
|
if (key === "NotebookOnly") { const on = this.hardwareCategories.find(x => x.key === "NotebookOnly")?.include; if (on) { set("NotebookAllIn", false); } }
|
||||||
|
},
|
||||||
|
|
||||||
|
// validation
|
||||||
|
validate() {
|
||||||
|
this.validation = { requiredDate: "", hardwarePurpose: "", sharedPerms: "" };
|
||||||
|
if (!this.model.requiredDate) this.validation.requiredDate = "Required Date is mandatory.";
|
||||||
|
else if (this.model.requiredDate < this.minReqISO) this.validation.requiredDate = "Required Date must be at least 7 days from today.";
|
||||||
|
if (this.hardwareCount > 0 && !this.hardwarePurpose) this.validation.hardwarePurpose = "Please select a Hardware Purpose.";
|
||||||
|
if (this.sharedPerms.length > 6) this.validation.sharedPerms = "Maximum 6 shared permissions.";
|
||||||
|
return !this.validation.requiredDate && !this.validation.hardwarePurpose && !this.validation.sharedPerms;
|
||||||
|
},
|
||||||
|
|
||||||
|
// dto
|
||||||
|
buildDto() {
|
||||||
|
const hardware = [];
|
||||||
|
const justification = this.hardwareJustification || "";
|
||||||
|
const purpose = this.hardwarePurpose || "";
|
||||||
|
this.hardwareCategories.forEach(c => { if (c.include) hardware.push({ category: c.key, purpose, justification, otherDescription: "" }); });
|
||||||
|
if (this.hardwareOther.trim()) { hardware.push({ category: "Other", purpose, justification, otherDescription: this.hardwareOther.trim() }); }
|
||||||
|
const emails = this.emailRows.map(x => ({ proposedAddress: x.proposedAddress || "" }));
|
||||||
|
const oSReqs = this.osReqs.map(x => ({ requirementText: x.requirementText }));
|
||||||
|
const software = [];
|
||||||
|
Object.keys(this.softwareGeneral).forEach(n => { if (this.softwareGeneral[n]) software.push({ bucket: "General", name: n, otherName: "", notes: "" }); });
|
||||||
|
Object.keys(this.softwareUtility).forEach(n => { if (this.softwareUtility[n]) software.push({ bucket: "Utility", name: n, otherName: "", notes: "" }); });
|
||||||
|
if (this.softwareGeneralOther?.trim()) software.push({ bucket: "General", name: "Others", otherName: this.softwareGeneralOther.trim(), notes: "" });
|
||||||
|
if (this.softwareUtilityOther?.trim()) software.push({ bucket: "Utility", name: "Others", otherName: this.softwareUtilityOther.trim(), notes: "" });
|
||||||
|
if (this.softwareCustomOther?.trim()) software.push({ bucket: "Custom", name: "Others", otherName: this.softwareCustomOther.trim(), notes: "" });
|
||||||
|
|
||||||
|
const sharedPerms = this.sharedPerms.slice(0, 6).map(x => ({
|
||||||
|
shareName: x.shareName || "", canRead: !!x.canRead, canWrite: !!x.canWrite, canDelete: !!x.canDelete, canRemove: !!x.canRemove
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
staffName: this.model.staffName, companyName: this.model.companyName, departmentName: this.model.departmentName,
|
||||||
|
designation: this.model.designation, location: this.model.location, employmentStatus: this.model.employmentStatus,
|
||||||
|
contractEndDate: this.model.contractEndDate || null, requiredDate: this.model.requiredDate, phoneExt: this.model.phoneExt,
|
||||||
|
hardware, emails, oSReqs, software, sharedPerms
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
try {
|
||||||
|
this.busy = true;
|
||||||
|
const r = await fetch(`/ItRequestAPI/request/${statusId}`); if (!r.ok) throw new Error('Failed to load request');
|
||||||
|
const j = await r.json();
|
||||||
|
|
||||||
|
const req = j.request || {};
|
||||||
|
this.model.staffName = req.staffName || ""; this.model.companyName = req.companyName || "";
|
||||||
|
this.model.departmentName = req.departmentName || ""; this.model.designation = req.designation || "";
|
||||||
|
this.model.location = req.location || ""; this.model.employmentStatus = req.employmentStatus || "";
|
||||||
|
this.model.contractEndDate = req.contractEndDate || null;
|
||||||
|
this.model.requiredDate = req.requiredDate ? req.requiredDate.substring(0, 10) : "";
|
||||||
|
this.model.phoneExt = req.phoneExt || "";
|
||||||
|
|
||||||
|
this.hardwarePurpose = ""; this.hardwareJustification = ""; this.hardwareOther = "";
|
||||||
|
this.hardwareCategories.forEach(x => x.include = false);
|
||||||
|
(j.hardware || []).forEach(h => {
|
||||||
|
if (h.purpose && !this.hardwarePurpose) this.hardwarePurpose = h.purpose;
|
||||||
|
if (h.justification && !this.hardwareJustification) this.hardwareJustification = h.justification;
|
||||||
|
if (h.category === "Other") { if (h.otherDescription) this.hardwareOther = h.otherDescription; }
|
||||||
|
else { const t = this.hardwareCategories.find(c => c.key === h.category); if (t) t.include = true; }
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emailRows = (j.emails || []).map(e => ({ proposedAddress: e.proposedAddress || "" }));
|
||||||
|
this.osReqs = (j.osreqs || []).map(o => ({ requirementText: o.requirementText || "" }));
|
||||||
|
|
||||||
|
this.softwareGeneral = {}; this.softwareUtility = {};
|
||||||
|
this.softwareGeneralOther = ""; this.softwareUtilityOther = ""; this.softwareCustomOther = "";
|
||||||
|
(j.software || []).forEach(sw => {
|
||||||
|
if (sw.bucket === "General" && sw.name !== "Others") this.softwareGeneral[sw.name] = true;
|
||||||
|
else if (sw.bucket === "Utility" && sw.name !== "Others") this.softwareUtility[sw.name] = true;
|
||||||
|
else if (sw.bucket === "General" && sw.name === "Others") this.softwareGeneralOther = sw.otherName || "";
|
||||||
|
else if (sw.bucket === "Utility" && sw.name === "Others") this.softwareUtilityOther = sw.otherName || "";
|
||||||
|
else if (sw.bucket === "Custom") this.softwareCustomOther = sw.otherName || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shared perms from API (if present)
|
||||||
|
this.sharedPerms = (j.sharedPerms || []).map(sp => ({
|
||||||
|
shareName: sp.shareName || "",
|
||||||
|
canRead: !!sp.canRead, canWrite: !!sp.canWrite,
|
||||||
|
canDelete: !!sp.canDelete, canRemove: !!sp.canRemove
|
||||||
|
})).slice(0, 6);
|
||||||
|
|
||||||
|
this.isEditable = !!(j.edit && j.edit.isEditable);
|
||||||
|
this.remaining = (j.edit && j.edit.remainingSeconds) || 0;
|
||||||
|
|
||||||
|
this.startCountdown(); this.startSyncRemaining();
|
||||||
|
} catch (e) { alert(e.message || 'Load error'); } finally { this.busy = false; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
if (!this.isEditable) return;
|
||||||
|
if (!this.validate()) return;
|
||||||
|
try {
|
||||||
|
this.busy = true;
|
||||||
|
const dto = this.buildDto();
|
||||||
|
const r = await fetch(`/ItRequestAPI/edit/${statusId}`, {
|
||||||
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(Object.assign({ statusId: +statusId }, dto))
|
||||||
|
});
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(j.message || 'Save failed');
|
||||||
|
if (typeof j.remainingSeconds === 'number') { this.remaining = j.remainingSeconds; this.startCountdown(); }
|
||||||
|
} catch (e) { alert(e.message || 'Save error'); } finally { this.busy = false; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async sendNow() {
|
||||||
|
if (!this.isEditable) return;
|
||||||
|
if (!this.validate()) return;
|
||||||
|
if (!confirm('Send to approvals now? You won’t be able to edit after this.')) return;
|
||||||
|
try {
|
||||||
|
this.busy = true;
|
||||||
|
const r = await fetch(`/ItRequestAPI/sendNow/${statusId}`, { method: 'POST' });
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok) throw new Error(j.message || 'Send failed');
|
||||||
|
alert('Sent to approvals.');
|
||||||
|
window.location.href = `/IT/ApprovalDashboard/MyRequests`;
|
||||||
|
} catch (e) { alert(e.message || 'Send error'); } finally { this.busy = false; }
|
||||||
|
},
|
||||||
|
|
||||||
|
async reload() { this.teardownTimers(); await this.load(); }
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.load();
|
||||||
|
window.addEventListener('beforeunload', this.teardownTimers);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.mount('#editApp');
|
||||||
|
</script>
|
||||||
|
}
|
||||||
557
Areas/IT/Views/ApprovalDashboard/MyRequests.cshtml
Normal file
557
Areas/IT/Views/ApprovalDashboard/MyRequests.cshtml
Normal file
@ -0,0 +1,557 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "My IT Requests";
|
||||||
|
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||||
|
}
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.container-outer {
|
||||||
|
max-width: 1300px;
|
||||||
|
margin: auto;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 6px 18px rgba(0,0,0,.06);
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters .form-label {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs .badge {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 18px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty i {
|
||||||
|
display: block;
|
||||||
|
font-size: 22px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
position: relative;
|
||||||
|
background: #f1f5f9;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,.6), transparent);
|
||||||
|
animation: shimmer 1.2s infinite;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pillbar button {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title .hint {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="myReqApp" class="container-outer">
|
||||||
|
<h3 class="mb-4 fw-bold"></h3>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<div class="row mb-3 align-items-end filters">
|
||||||
|
<div class="col-md-auto me-3">
|
||||||
|
<label class="form-label">Month</label>
|
||||||
|
<select class="form-control form-control-sm" v-model.number="selectedMonth" @@change="fetchData">
|
||||||
|
<option v-for="(m, i) in months" :key="i" :value="i + 1">{{ m }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-auto">
|
||||||
|
<label class="form-label">Year</label>
|
||||||
|
<select class="form-control form-control-sm" v-model.number="selectedYear" @@change="fetchData">
|
||||||
|
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
|
||||||
|
<div v-if="busy" class="alert alert-secondary py-2">Loading…</div>
|
||||||
|
|
||||||
|
<!-- ===================== TABLE 1: MAIN REQUESTS (Draft/Pending/Approved/Rejected/Cancelled) ===================== -->
|
||||||
|
<div class="table-container table-responsive">
|
||||||
|
<ul class="nav nav-tabs mb-3">
|
||||||
|
<li class="nav-item"><a class="nav-link" :class="{ active: activeTab === 'Draft' }" href="#" @@click.prevent="switchTab('Draft')">Draft <span class="badge bg-info">{{ counts.draft }}</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" :class="{ active: activeTab === 'Pending' }" href="#" @@click.prevent="switchTab('Pending')">Pending <span class="badge bg-warning text-dark">{{ counts.pending }}</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" :class="{ active: activeTab === 'Approved' }" href="#" @@click.prevent="switchTab('Approved')">Approved <span class="badge bg-success">{{ counts.approved }}</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" :class="{ active: activeTab === 'Rejected' }" href="#" @@click.prevent="switchTab('Rejected')">Rejected <span class="badge bg-danger">{{ counts.rejected }}</span></a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" :class="{ active: activeTab === 'Cancelled' }" href="#" @@click.prevent="switchTab('Cancelled')">Cancelled <span class="badge bg-secondary">{{ counts.cancelled }}</span></a></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<table class="table table-bordered table-sm table-striped align-middle text-center">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Department</th>
|
||||||
|
<th>Company</th>
|
||||||
|
<th>Required Date</th>
|
||||||
|
<th>Date Submitted</th>
|
||||||
|
<th style="width:260px;">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody v-if="busy">
|
||||||
|
<tr v-for="n in 5" :key="'sk-main-'+n">
|
||||||
|
<td colspan="5"><div class="skeleton"></div></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
|
||||||
|
<tbody v-else>
|
||||||
|
<tr v-for="r in paginatedData" :key="'row-'+activeTab+'-'+r.statusId">
|
||||||
|
<td>{{ r.departmentName }}</td>
|
||||||
|
<td>{{ r.companyName }}</td>
|
||||||
|
<td>{{ fmtDate(r.requiredDate) }}</td>
|
||||||
|
<td>{{ fmtDateTime(r.submitDate) }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex justify-content-center align-items-center">
|
||||||
|
<!-- View: Draft -> Edit page, others -> RequestReview -->
|
||||||
|
<button class="btn btn-primary btn-sm me-1" @@click="view(r)">View</button>
|
||||||
|
|
||||||
|
<!-- Cancel: Draft only (server enforces final business rules) -->
|
||||||
|
<button v-if="activeTab==='Draft'"
|
||||||
|
class="btn btn-outline-danger btn-sm"
|
||||||
|
:disabled="cancellingId === r.itRequestId"
|
||||||
|
@@click="cancel(r.itRequestId)">
|
||||||
|
<span v-if="cancellingId === r.itRequestId" class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!paginatedData.length" key="empty-main">
|
||||||
|
<td colspan="5" class="empty"><i class="bi bi-inboxes"></i> No {{ activeTab.toLowerCase() }} requests</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Pagination (Main) -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-2" v-if="filteredData.length">
|
||||||
|
<small class="text-muted">
|
||||||
|
Showing {{ (currentPage - 1) * itemsPerPage + 1 }} – {{ Math.min(currentPage * itemsPerPage, filteredData.length) }}
|
||||||
|
of {{ filteredData.length }}
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<div class="text-muted">Rows</div>
|
||||||
|
<select class="form-select form-select-sm" style="width:90px" v-model.number="itemsPerPage" @@change="currentPage=1">
|
||||||
|
<option :value="5">5</option>
|
||||||
|
<option :value="10">10</option>
|
||||||
|
<option :value="20">20</option>
|
||||||
|
<option :value="50">50</option>
|
||||||
|
</select>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" :disabled="currentPage===1" @@click="currentPage--">Prev</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" :disabled="currentPage * itemsPerPage >= filteredData.length" @@click="currentPage++">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===================== TABLE 2: SECTION B (SECOND TABLE) ===================== -->
|
||||||
|
<div class="table-container table-responsive">
|
||||||
|
<div class="section-title">
|
||||||
|
<span><i class="bi bi-clipboard-check"></i> Section B</span>
|
||||||
|
<span class="hint">Requests with overall status Approved/Completed</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section B sub-filters + callout -->
|
||||||
|
<div class="d-flex align-items-center justify-content-between flex-wrap mb-2">
|
||||||
|
<div class="pillbar">
|
||||||
|
<span class="text-muted me-2">Show:</span>
|
||||||
|
<button type="button" class="btn btn-sm"
|
||||||
|
:class="sbSubTab==='need' ? 'btn-primary' : 'btn-outline-secondary'"
|
||||||
|
@@click="setSbSubTab('need')">
|
||||||
|
Your Acceptance <span class="badge bg-light text-dark ms-1">{{ sbCount.need }}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm"
|
||||||
|
:class="sbSubTab==='waiting' ? 'btn-primary' : 'btn-outline-secondary'"
|
||||||
|
@@click="setSbSubTab('waiting')">
|
||||||
|
Waiting IT <span class="badge bg-light text-dark ms-1">{{ sbCount.waiting }}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm"
|
||||||
|
:class="sbSubTab==='notstarted' ? 'btn-primary' : 'btn-outline-secondary'"
|
||||||
|
@@click="setSbSubTab('notstarted')">
|
||||||
|
Not Started <span class="badge bg-light text-dark ms-1">{{ sbCount.notStarted }}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm"
|
||||||
|
:class="sbSubTab==='complete' ? 'btn-primary' : 'btn-outline-secondary'"
|
||||||
|
@@click="setSbSubTab('complete')">
|
||||||
|
Complete <span class="badge bg-light text-dark ms-1">{{ sbCount.complete }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="sbCount.need > 0" class="alert alert-warning py-1 px-2 m-0">
|
||||||
|
You have {{ sbCount.need }} Section B {{ sbCount.need===1 ? 'item' : 'items' }} that need your acceptance.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="table table-bordered table-sm table-striped align-middle text-center">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Department</th>
|
||||||
|
<th>Company</th>
|
||||||
|
<th>Section B Status</th>
|
||||||
|
<th style="width:360px;">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<!-- Skeleton while meta loads -->
|
||||||
|
<tbody v-if="busy && !sbLoaded">
|
||||||
|
<tr v-for="n in 4" :key="'sk-sb-'+n">
|
||||||
|
<td colspan="4"><div class="skeleton"></div></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
|
||||||
|
<!-- Section B rows -->
|
||||||
|
<tbody v-else>
|
||||||
|
<tr v-for="r in sectionBPage" :key="'sb-'+r.statusId">
|
||||||
|
<td>{{ r.departmentName }}</td>
|
||||||
|
<td>{{ r.companyName }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge"
|
||||||
|
:class="r.sb.itAccepted && r.sb.requestorAccepted ? 'bg-success'
|
||||||
|
: (r.sb.saved ? 'bg-warning text-dark' : 'bg-secondary')">
|
||||||
|
<template v-if="r.sb.itAccepted && r.sb.requestorAccepted">Approved</template>
|
||||||
|
<template v-else-if="r.sb.saved && !r.sb.requestorAccepted">Your Acceptance</template>
|
||||||
|
<template v-else-if="r.sb.saved && r.sb.requestorAccepted && !r.sb.itAccepted">Waiting IT</template>
|
||||||
|
<template v-else>Not Started</template>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<!-- ACTION RULES BY SUBTAB -->
|
||||||
|
<div class="d-flex justify-content-center align-items-center flex-wrap" style="gap:6px;">
|
||||||
|
<!-- Always show View -->
|
||||||
|
<button class="btn btn-primary btn-sm" @@click="openSectionB(r.statusId)">View</button>
|
||||||
|
|
||||||
|
<!-- Your Acceptance tab ONLY: show Accept (Requestor) -->
|
||||||
|
<button v-if="sbSubTab==='need'"
|
||||||
|
class="btn btn-outline-dark btn-sm"
|
||||||
|
:disabled="busy || !r.sb.saved || r.sb.requestorAccepted"
|
||||||
|
@@click="acceptRequestor(r.statusId)">
|
||||||
|
Accept (Requestor)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Complete tab ONLY: show PDF -->
|
||||||
|
<button v-if="sbSubTab==='complete' && r.sb.itAccepted && r.sb.requestorAccepted"
|
||||||
|
class="btn btn-outline-secondary btn-sm"
|
||||||
|
@@click="downloadPdf(r.statusId)">
|
||||||
|
PDF
|
||||||
|
</button>
|
||||||
|
<!-- Not Started / Waiting IT: no other actions (View only) -->
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<tr v-if="sbLoaded && sectionBFiltered.length === 0">
|
||||||
|
<td colspan="4" class="empty"><i class="bi bi-inboxes"></i> No Section B items in this view</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Pagination (Section B) -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-2" v-if="sectionBFiltered.length">
|
||||||
|
<small class="text-muted">
|
||||||
|
Showing {{ (sectionBPageIndex - 1) * itemsPerPage + 1 }} – {{ Math.min(sectionBPageIndex * itemsPerPage, sectionBFiltered.length) }}
|
||||||
|
of {{ sectionBFiltered.length }}
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" :disabled="sectionBPageIndex===1" @@click="sectionBPageIndex--">Prev</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" :disabled="sectionBPageIndex * itemsPerPage >= sectionBFiltered.length" @@click="sectionBPageIndex++">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
const app = Vue.createApp({
|
||||||
|
data() {
|
||||||
|
const now = new Date();
|
||||||
|
return {
|
||||||
|
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
|
||||||
|
years: Array.from({ length: 10 }, (_, i) => now.getFullYear() - 5 + i),
|
||||||
|
selectedMonth: now.getMonth() + 1,
|
||||||
|
selectedYear: now.getFullYear(),
|
||||||
|
|
||||||
|
busy: false, error: null, allRows: [],
|
||||||
|
// MAIN table
|
||||||
|
activeTab: 'Pending',
|
||||||
|
currentPage: 1, itemsPerPage: 10,
|
||||||
|
cancellingId: 0,
|
||||||
|
|
||||||
|
// SECTION B (second table)
|
||||||
|
sbMetaMap: {}, // statusId -> { saved, requestorAccepted, itAccepted, lastEditedBy }
|
||||||
|
sbLoaded: false,
|
||||||
|
sectionBPageIndex: 1,
|
||||||
|
sbSubTab: 'need', // 'need' | 'waiting' | 'notstarted' | 'complete'
|
||||||
|
|
||||||
|
// Which overall statuses are eligible for Section B
|
||||||
|
SECTIONB_ALLOW_STATUSES: ['Approved', 'Completed']
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
// Badge counts for main tabs
|
||||||
|
counts() {
|
||||||
|
const c = { draft: 0, pending: 0, approved: 0, rejected: 0, cancelled: 0 };
|
||||||
|
this.allRows.forEach(r => {
|
||||||
|
const s = (r.overallStatus || 'Pending');
|
||||||
|
if (s === 'Draft') c.draft++;
|
||||||
|
else if (s === 'Pending') c.pending++;
|
||||||
|
else if (s === 'Approved') c.approved++;
|
||||||
|
else if (s === 'Rejected') c.rejected++;
|
||||||
|
else if (s === 'Cancelled') c.cancelled++;
|
||||||
|
});
|
||||||
|
return c;
|
||||||
|
},
|
||||||
|
|
||||||
|
// MAIN table filtering & pagination
|
||||||
|
filteredData() {
|
||||||
|
const tab = this.activeTab;
|
||||||
|
return this.allRows.filter(r => (r.overallStatus || 'Pending') === tab);
|
||||||
|
},
|
||||||
|
paginatedData() {
|
||||||
|
const start = (this.currentPage - 1) * this.itemsPerPage;
|
||||||
|
return this.filteredData.slice(start, start + this.itemsPerPage);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Build Section B candidate rows and sort by priority
|
||||||
|
sectionBRows() {
|
||||||
|
const rows = this.allRows
|
||||||
|
.filter(r => this.SECTIONB_ALLOW_STATUSES.includes(r.overallStatus || ''))
|
||||||
|
.map(r => ({
|
||||||
|
...r,
|
||||||
|
sb: this.sbMetaMap[r.statusId] || { saved: false, requestorAccepted: false, itAccepted: false, lastEditedBy: null }
|
||||||
|
}));
|
||||||
|
const rank = (x) => {
|
||||||
|
if (x.sb.itAccepted && x.sb.requestorAccepted) return 3; // complete (lowest priority)
|
||||||
|
if (!x.sb.saved) return 1; // not started
|
||||||
|
if (x.sb.saved && x.sb.requestorAccepted && !x.sb.itAccepted) return 2; // waiting IT
|
||||||
|
if (x.sb.saved && !x.sb.requestorAccepted) return 4; // needs requestor (highest)
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
return rows.slice().sort((a, b) => {
|
||||||
|
const rb = rank(b) - rank(a);
|
||||||
|
if (rb !== 0) return rb;
|
||||||
|
return new Date(b.submitDate) - new Date(a.submitDate);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Section B counts for pills
|
||||||
|
sbCount() {
|
||||||
|
const c = { need: 0, waiting: 0, notStarted: 0, complete: 0 };
|
||||||
|
this.sectionBRows.forEach(r => {
|
||||||
|
const st = this.sbStageOf(r.sb);
|
||||||
|
if (st === 'need') c.need++;
|
||||||
|
else if (st === 'waiting') c.waiting++;
|
||||||
|
else if (st === 'notstarted') c.notStarted++;
|
||||||
|
else if (st === 'complete') c.complete++;
|
||||||
|
});
|
||||||
|
return c;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Apply Section B sub-filter & pagination
|
||||||
|
sectionBFiltered() {
|
||||||
|
const want = this.sbSubTab; // 'need'|'waiting'|'notstarted'|'complete'
|
||||||
|
return this.sectionBRows.filter(r => this.sbStageOf(r.sb) === want);
|
||||||
|
},
|
||||||
|
sectionBPage() {
|
||||||
|
const start = (this.sectionBPageIndex - 1) * this.itemsPerPage;
|
||||||
|
return this.sectionBFiltered.slice(start, start + this.itemsPerPage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// MAIN table
|
||||||
|
switchTab(tab) {
|
||||||
|
this.activeTab = tab;
|
||||||
|
this.currentPage = 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
async fetchData() {
|
||||||
|
try {
|
||||||
|
this.busy = true; this.error = null;
|
||||||
|
const y = this.selectedYear, m = this.selectedMonth;
|
||||||
|
|
||||||
|
// Inclusive month range (UTC)
|
||||||
|
const from = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0));
|
||||||
|
const to = new Date(Date.UTC(y, m, 0, 23, 59, 59, 999));
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('from', from.toISOString());
|
||||||
|
params.set('to', to.toISOString());
|
||||||
|
params.set('page', '1');
|
||||||
|
params.set('pageSize', '500');
|
||||||
|
|
||||||
|
const res = await fetch(`/ItRequestAPI/myRequests?${params.toString()}`);
|
||||||
|
if (!res.ok) throw new Error(`Load failed (${res.status})`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
this.allRows = (data.data || []).map(x => ({
|
||||||
|
itRequestId: x.itRequestId,
|
||||||
|
statusId: x.statusId,
|
||||||
|
departmentName: x.departmentName,
|
||||||
|
companyName: x.companyName,
|
||||||
|
requiredDate: x.requiredDate,
|
||||||
|
submitDate: x.submitDate,
|
||||||
|
overallStatus: x.overallStatus || 'Pending'
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Reset Section B cache and load fresh meta
|
||||||
|
this.sbMetaMap = {};
|
||||||
|
this.sbLoaded = false;
|
||||||
|
await this.loadSectionBMeta();
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message || 'Failed to load.';
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fmtDate(d) { if (!d) return ''; const dt = new Date(d); return isNaN(dt) ? d : dt.toLocaleDateString(); },
|
||||||
|
fmtDateTime(d) { if (!d) return ''; const dt = new Date(d); return isNaN(dt) ? d : dt.toLocaleString(); },
|
||||||
|
|
||||||
|
view(row) {
|
||||||
|
if (this.activeTab === 'Draft') {
|
||||||
|
window.location.href = `/IT/ApprovalDashboard/Edit?statusId=${row.statusId}`;
|
||||||
|
} else {
|
||||||
|
window.location.href = `/IT/ApprovalDashboard/RequestReview?statusId=${row.statusId}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancel(requestId) {
|
||||||
|
if (!confirm('Cancel this request? This cannot be undone.')) return;
|
||||||
|
this.cancellingId = requestId;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/ItRequestAPI/cancel', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ requestId, reason: 'User requested cancellation' })
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data?.message || 'Cancel failed');
|
||||||
|
await this.fetchData();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + (e.message || e));
|
||||||
|
} finally {
|
||||||
|
this.cancellingId = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========= Section B (second table) =========
|
||||||
|
openSectionB(statusId) {
|
||||||
|
const here = window.location.pathname + window.location.search + window.location.hash;
|
||||||
|
const returnUrl = encodeURIComponent(here);
|
||||||
|
window.location.href = `/IT/ApprovalDashboard/SectionB?statusId=${statusId}&returnUrl=${returnUrl}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
async acceptRequestor(statusId) {
|
||||||
|
try {
|
||||||
|
this.busy = true;
|
||||||
|
const res = await fetch('/ItRequestAPI/sectionB/accept', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ statusId, by: 'REQUESTOR' })
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j.message || 'Accept failed');
|
||||||
|
|
||||||
|
// refresh only this row's meta
|
||||||
|
await this.loadSectionBMeta([statusId]);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message || 'Action failed');
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
downloadPdf(statusId) { window.open(`/ItRequestAPI/sectionB/pdf?statusId=${statusId}`, '_blank'); },
|
||||||
|
|
||||||
|
// Derive requestor-centric stage
|
||||||
|
sbStageOf(sb) {
|
||||||
|
if (sb.itAccepted && sb.requestorAccepted) return 'complete';
|
||||||
|
if (sb.saved && !sb.requestorAccepted) return 'need';
|
||||||
|
if (sb.saved && sb.requestorAccepted && !sb.itAccepted) return 'waiting';
|
||||||
|
return 'notstarted';
|
||||||
|
},
|
||||||
|
|
||||||
|
// Load meta for all eligible Section B rows (or subset)
|
||||||
|
async loadSectionBMeta(whichStatusIds = null) {
|
||||||
|
try {
|
||||||
|
const targets = (whichStatusIds && whichStatusIds.length)
|
||||||
|
? whichStatusIds
|
||||||
|
: this.allRows
|
||||||
|
.filter(r => this.SECTIONB_ALLOW_STATUSES.includes(r.overallStatus || ''))
|
||||||
|
.map(r => r.statusId);
|
||||||
|
|
||||||
|
if (!targets.length) { this.sbLoaded = true; return; }
|
||||||
|
|
||||||
|
for (const sid of targets) {
|
||||||
|
const res = await fetch(`/ItRequestAPI/sectionB/meta?statusId=${sid}`);
|
||||||
|
if (!res.ok) continue;
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
this.sbMetaMap[sid] = {
|
||||||
|
saved: !!j.sectionB?.saved,
|
||||||
|
requestorAccepted: !!j.requestorAccepted,
|
||||||
|
itAccepted: !!j.itAccepted,
|
||||||
|
lastEditedBy: j.sectionB?.lastEditedBy || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.sbLoaded = true;
|
||||||
|
} catch {
|
||||||
|
this.sbLoaded = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setSbSubTab(tab) {
|
||||||
|
this.sbSubTab = tab;
|
||||||
|
this.sectionBPageIndex = 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() { this.fetchData(); }
|
||||||
|
});
|
||||||
|
app.mount('#myReqApp');
|
||||||
|
</script>
|
||||||
|
}
|
||||||
632
Areas/IT/Views/ApprovalDashboard/RequestReview.cshtml
Normal file
632
Areas/IT/Views/ApprovalDashboard/RequestReview.cshtml
Normal file
@ -0,0 +1,632 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "IT Request Review";
|
||||||
|
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||||
|
}
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root{
|
||||||
|
--card-radius:16px;
|
||||||
|
--soft-shadow:0 8px 24px rgba(0,0,0,.08);
|
||||||
|
--soft-border:#eef2f6;
|
||||||
|
--chip-pending:#ffe599; /* soft amber */
|
||||||
|
--chip-approved:#b7e1cd; /* soft green */
|
||||||
|
--chip-rejected:#f8b4b4; /* soft red */
|
||||||
|
--text-muted:#6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shell */
|
||||||
|
#reviewApp{
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: auto;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page header */
|
||||||
|
.page-head{
|
||||||
|
display:flex; align-items:center; justify-content:space-between;
|
||||||
|
gap:1rem; margin-bottom:1rem;
|
||||||
|
}
|
||||||
|
.page-title{
|
||||||
|
display:flex; align-items:center; gap:.75rem; margin:0;
|
||||||
|
font-weight:700; letter-spacing:.2px;
|
||||||
|
}
|
||||||
|
.subtle{
|
||||||
|
color:var(--text-muted);
|
||||||
|
font-weight:500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.ui-card{
|
||||||
|
background:#fff; border-radius:var(--card-radius);
|
||||||
|
box-shadow:var(--soft-shadow); border:1px solid var(--soft-border);
|
||||||
|
overflow:hidden; margin-bottom:18px;
|
||||||
|
}
|
||||||
|
.ui-card-head{
|
||||||
|
display:flex; align-items:center; justify-content:space-between;
|
||||||
|
padding:14px 18px; border-bottom:1px solid var(--soft-border);
|
||||||
|
background:linear-gradient(180deg,#fbfdff, #f7fafc);
|
||||||
|
}
|
||||||
|
.ui-card-head h6{ margin:0; font-weight:700; color:#0b5ed7; }
|
||||||
|
.ui-card-body{ padding:16px 18px; }
|
||||||
|
|
||||||
|
/* Requester grid */
|
||||||
|
.req-grid{
|
||||||
|
display:grid; grid-template-columns:repeat(2,1fr);
|
||||||
|
gap:10px 18px;
|
||||||
|
}
|
||||||
|
@@media (max-width:768px){ .req-grid{ grid-template-columns:1fr; } }
|
||||||
|
.req-line b{ color:#111827; }
|
||||||
|
.req-line span{ color:var(--text-muted); }
|
||||||
|
|
||||||
|
/* Chips */
|
||||||
|
.chip{
|
||||||
|
display:inline-flex; align-items:center; gap:.4rem;
|
||||||
|
padding:.3rem .6rem; border-radius:999px; font-weight:600; font-size:12px;
|
||||||
|
border:1px solid rgba(0,0,0,.05);
|
||||||
|
}
|
||||||
|
.chip i{ font-size:14px; }
|
||||||
|
.chip-pending{ background:var(--chip-pending); }
|
||||||
|
.chip-approved{ background:var(--chip-approved); }
|
||||||
|
.chip-rejected{ background:var(--chip-rejected); }
|
||||||
|
.chip-muted{ background:#e5e7eb; }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.nice-table{
|
||||||
|
width:100%; border-collapse:separate; border-spacing:0;
|
||||||
|
overflow:hidden; border-radius:12px; border:1px solid var(--soft-border);
|
||||||
|
}
|
||||||
|
.nice-table thead th{
|
||||||
|
background:#f3f6fb; color:#334155; font-weight:700; font-size:12px;
|
||||||
|
text-transform:uppercase; letter-spacing:.4px; border-bottom:1px solid var(--soft-border);
|
||||||
|
}
|
||||||
|
.nice-table th, .nice-table td{ padding:10px 12px; vertical-align:middle; }
|
||||||
|
.nice-table tbody tr + tr td{ border-top:1px solid #f1f5f9; }
|
||||||
|
.nice-table tbody tr:hover{ background:#fafcff; }
|
||||||
|
|
||||||
|
/* Boolean badges in table */
|
||||||
|
.yes-badge, .no-badge{
|
||||||
|
display:inline-block; padding:.25rem .5rem; font-size:12px; font-weight:700;
|
||||||
|
border-radius:999px;
|
||||||
|
}
|
||||||
|
.yes-badge{ background:#e7f7ed; color:#166534; border:1px solid #c7ecd3;}
|
||||||
|
.no-badge{ background:#eef2f7; color:#334155; border:1px solid #e1e7ef;}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty{
|
||||||
|
text-align:center; padding:18px; color:var(--text-muted);
|
||||||
|
}
|
||||||
|
.empty i{ display:block; font-size:22px; margin-bottom:6px; opacity:.7; }
|
||||||
|
|
||||||
|
/* Skeletons */
|
||||||
|
.skeleton{ position:relative; background:#f1f5f9; overflow:hidden; border-radius:6px; }
|
||||||
|
.skeleton::after{
|
||||||
|
content:""; position:absolute; inset:0;
|
||||||
|
background:linear-gradient(90deg, transparent, rgba(255,255,255,.6), transparent);
|
||||||
|
animation: shimmer 1.2s infinite;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
@@keyframes shimmer{
|
||||||
|
0%{ transform:translateX(-100%); }
|
||||||
|
100%{ transform:translateX(100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sticky action bar */
|
||||||
|
.action-bar{
|
||||||
|
position:sticky; bottom:12px; z-index:5;
|
||||||
|
display:flex; justify-content:flex-end; gap:10px;
|
||||||
|
padding:12px; border-radius:12px; background:rgba(255,255,255,.8);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
border:1px solid var(--soft-border); box-shadow:var(--soft-shadow);
|
||||||
|
margin-top:8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Soft buttons */
|
||||||
|
.btn-soft{
|
||||||
|
border-radius:10px; padding:.55rem .9rem; font-weight:700; letter-spacing:.2px;
|
||||||
|
border:1px solid transparent; box-shadow:0 2px 8px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
.btn-approve{ background:#22c55e; color:#fff; }
|
||||||
|
.btn-approve:hover{ background:#16a34a; }
|
||||||
|
.btn-reject{ background:#ef4444; color:#fff; }
|
||||||
|
.btn-reject:hover{ background:#dc2626; }
|
||||||
|
.btn-disabled{ background:#e5e7eb; color:#6b7280; cursor:not-allowed; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="reviewApp">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="page-head">
|
||||||
|
<h3 class="page-title">
|
||||||
|
</h3>
|
||||||
|
<div>
|
||||||
|
<span :class="overallChip.class">
|
||||||
|
<i :class="overallChip.icon"></i>
|
||||||
|
{{ overallChip.text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Requester Info -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-card-head">
|
||||||
|
<h6><i class="bi bi-person-badge"></i> Requester Info</h6>
|
||||||
|
<small class="subtle" v-if="!isLoading">Submitted: {{ formatDate(userInfo.submitDate) }}</small>
|
||||||
|
<div v-else class="skeleton" style="height:14px; width:160px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="ui-card-body">
|
||||||
|
<div class="req-grid">
|
||||||
|
<div class="req-line">
|
||||||
|
<b>Name</b><br>
|
||||||
|
<span v-if="!isLoading">{{ userInfo.staffName || '—' }}</span>
|
||||||
|
<div v-else class="skeleton" style="height:14px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="req-line">
|
||||||
|
<b>Department</b><br>
|
||||||
|
<span v-if="!isLoading">{{ userInfo.departmentName || '—' }}</span>
|
||||||
|
<div v-else class="skeleton" style="height:14px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="req-line">
|
||||||
|
<b>Company</b><br>
|
||||||
|
<span v-if="!isLoading">{{ userInfo.companyName || '—' }}</span>
|
||||||
|
<div v-else class="skeleton" style="height:14px;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="req-line">
|
||||||
|
<b>Designation</b><br>
|
||||||
|
<span v-if="!isLoading">{{ userInfo.designation || '—' }}</span>
|
||||||
|
<div v-else class="skeleton" style="height:14px;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hardware -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-card-head">
|
||||||
|
<h6><i class="bi bi-cpu"></i> Hardware Requested</h6>
|
||||||
|
</div>
|
||||||
|
<div class="ui-card-body">
|
||||||
|
<div v-if="isLoading">
|
||||||
|
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
|
||||||
|
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
|
||||||
|
<div class="skeleton" style="height:36px;"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<table class="nice-table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Purpose</th>
|
||||||
|
<th>Justification</th>
|
||||||
|
<th>Other</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in hardware" :key="item.id">
|
||||||
|
<td>{{ item.category }}</td>
|
||||||
|
<td>{{ item.purpose }}</td>
|
||||||
|
<td>{{ item.justification }}</td>
|
||||||
|
<td>{{ item.otherDescription }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="hardware.length === 0">
|
||||||
|
<td colspan="4" class="empty">
|
||||||
|
<i class="bi bi-inboxes"></i>
|
||||||
|
No hardware requested
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-card-head">
|
||||||
|
<h6><i class="bi bi-envelope-paper"></i> Email Requests</h6>
|
||||||
|
</div>
|
||||||
|
<div class="ui-card-body">
|
||||||
|
<div v-if="isLoading">
|
||||||
|
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
|
||||||
|
<div class="skeleton" style="height:36px;"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<table class="nice-table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Purpose</th>
|
||||||
|
<th>Proposed Address</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in emails" :key="item.id">
|
||||||
|
<td>{{ item.purpose }}</td>
|
||||||
|
<td>{{ item.proposedAddress }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="emails.length === 0">
|
||||||
|
<td colspan="2" class="empty">
|
||||||
|
<i class="bi bi-inboxes"></i>
|
||||||
|
No email requests
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OS Requirements -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-card-head">
|
||||||
|
<h6><i class="bi bi-windows"></i> Operating System Requirements</h6>
|
||||||
|
</div>
|
||||||
|
<div class="ui-card-body">
|
||||||
|
<div v-if="isLoading">
|
||||||
|
<div class="skeleton" style="height:36px;"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<table class="nice-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Requirement</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in osreqs" :key="item.id">
|
||||||
|
<td>{{ item.requirementText }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="osreqs.length === 0">
|
||||||
|
<td class="empty">
|
||||||
|
<i class="bi bi-inboxes"></i>
|
||||||
|
No OS requirements
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Software -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-card-head">
|
||||||
|
<h6><i class="bi bi-boxes"></i> Software Requested</h6>
|
||||||
|
</div>
|
||||||
|
<div class="ui-card-body">
|
||||||
|
<div v-if="isLoading">
|
||||||
|
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
|
||||||
|
<div class="skeleton" style="height:36px;"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<table class="nice-table table-striped">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Bucket</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Other</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in software" :key="item.id">
|
||||||
|
<td>{{ item.bucket }}</td>
|
||||||
|
<td>{{ item.name }}</td>
|
||||||
|
<td>{{ item.otherName }}</td>
|
||||||
|
<td>{{ item.notes }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="software.length === 0">
|
||||||
|
<td colspan="4" class="empty">
|
||||||
|
<i class="bi bi-inboxes"></i>
|
||||||
|
No software requested
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Shared Permissions -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-card-head">
|
||||||
|
<h6><i class="bi bi-folder-symlink"></i> Shared Folder / Permission Requests</h6>
|
||||||
|
</div>
|
||||||
|
<div class="ui-card-body">
|
||||||
|
<div v-if="isLoading">
|
||||||
|
<div class="skeleton" style="height:36px;"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<table class="nice-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Share Name</th>
|
||||||
|
<th>Read</th>
|
||||||
|
<th>Write</th>
|
||||||
|
<th>Delete</th>
|
||||||
|
<th>Remove</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in sharedPerms" :key="item.id">
|
||||||
|
<td>{{ item.shareName }}</td>
|
||||||
|
<td><span :class="item.canRead ? 'yes-badge' : 'no-badge'">{{ item.canRead ? 'Yes' : 'No' }}</span></td>
|
||||||
|
<td><span :class="item.canWrite ? 'yes-badge' : 'no-badge'">{{ item.canWrite ? 'Yes' : 'No' }}</span></td>
|
||||||
|
<td><span :class="item.canDelete ? 'yes-badge' : 'no-badge'">{{ item.canDelete ? 'Yes' : 'No' }}</span></td>
|
||||||
|
<td><span :class="item.canRemove ? 'yes-badge' : 'no-badge'">{{ item.canRemove ? 'Yes' : 'No' }}</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="sharedPerms.length === 0">
|
||||||
|
<td colspan="5" class="empty">
|
||||||
|
<i class="bi bi-inboxes"></i>
|
||||||
|
No shared permissions requested
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Approval Trail -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-card-head">
|
||||||
|
<h6><i class="bi bi-flag"></i> Approval Trail</h6>
|
||||||
|
</div>
|
||||||
|
<div class="ui-card-body">
|
||||||
|
<div v-if="isLoading">
|
||||||
|
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
|
||||||
|
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
|
||||||
|
<div class="skeleton" style="height:36px;"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<table class="nice-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Stage</th>
|
||||||
|
<th>Status</th>
|
||||||
|
|
||||||
|
<th>Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>HOD</td>
|
||||||
|
<td><span :class="badgeChip(status.hodStatus).class"><i :class="badgeChip(status.hodStatus).icon"></i>{{ status.hodStatus || '—' }}</span></td>
|
||||||
|
|
||||||
|
<td>{{ formatDate(status.hodSubmitDate) || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Group IT HOD</td>
|
||||||
|
<td><span :class="badgeChip(status.gitHodStatus).class"><i :class="badgeChip(status.gitHodStatus).icon"></i>{{ status.gitHodStatus || '—' }}</span></td>
|
||||||
|
|
||||||
|
<td>{{ formatDate(status.gitHodSubmitDate) || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Finance HOD</td>
|
||||||
|
<td><span :class="badgeChip(status.finHodStatus).class"><i :class="badgeChip(status.finHodStatus).icon"></i>{{ status.finHodStatus || '—' }}</span></td>
|
||||||
|
|
||||||
|
<td>{{ formatDate(status.finHodSubmitDate) || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Management</td>
|
||||||
|
<td><span :class="badgeChip(status.mgmtStatus).class"><i :class="badgeChip(status.mgmtStatus).icon"></i>{{ status.mgmtStatus || '—' }}</span></td>
|
||||||
|
|
||||||
|
<td>{{ formatDate(status.mgmtSubmitDate) || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sticky Action Bar -->
|
||||||
|
<div class="action-bar">
|
||||||
|
<template v-if="status.canApprove">
|
||||||
|
<button class="btn-soft btn-approve" @@click="updateStatus('Approved')">
|
||||||
|
<i class="bi bi-check2-circle"></i> Approve
|
||||||
|
</button>
|
||||||
|
<button class="btn-soft btn-reject" @@click="updateStatus('Rejected')">
|
||||||
|
<i class="bi bi-x-circle"></i> Reject
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<button class="btn-soft btn-disabled" disabled>
|
||||||
|
<i class="bi bi-shield-lock"></i> You cannot act on this request right now
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const reviewApp = Vue.createApp({
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isLoading: true,
|
||||||
|
userInfo: {
|
||||||
|
staffName: "", departmentName: "", companyName: "",
|
||||||
|
designation: "", submitDate: ""
|
||||||
|
},
|
||||||
|
hardware: [],
|
||||||
|
emails: [],
|
||||||
|
osreqs: [],
|
||||||
|
software: [],
|
||||||
|
sharedPerms: [],
|
||||||
|
status: {
|
||||||
|
hodStatus: "Pending", gitHodStatus: "Pending",
|
||||||
|
finHodStatus: "Pending", mgmtStatus: "Pending",
|
||||||
|
|
||||||
|
hodSubmitDate: "", gitHodSubmitDate: "", finHodSubmitDate: "", mgmtSubmitDate: "",
|
||||||
|
overallStatus: "Pending", canApprove: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed:{
|
||||||
|
overallChip(){
|
||||||
|
const s = (this.status.overallStatus || 'Pending').toLowerCase();
|
||||||
|
if(s==='approved') return { class:'chip chip-approved', icon:'bi bi-check2-circle', text:'Overall: Approved' };
|
||||||
|
if(s==='rejected') return { class:'chip chip-rejected', icon:'bi bi-x-circle', text:'Overall: Rejected' };
|
||||||
|
return { class:'chip chip-pending', icon:'bi bi-hourglass-split', text:`Overall: ${this.status.overallStatus || 'Pending'}` };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
badgeChip(v){
|
||||||
|
const s = (v || 'Pending').toLowerCase();
|
||||||
|
if(s==='approved') return { class:'chip chip-approved', icon:'bi bi-check2' };
|
||||||
|
if(s==='rejected') return { class:'chip chip-rejected', icon:'bi bi-x-lg' };
|
||||||
|
if(s==='pending') return { class:'chip chip-pending', icon:'bi bi-hourglass' };
|
||||||
|
return { class:'chip chip-muted', icon:'bi bi-dot' };
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== Load existing (full function) =====
|
||||||
|
async loadRequest() {
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
// 1) Read statusId from URL
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const statusId = params.get("statusId");
|
||||||
|
if (!statusId) {
|
||||||
|
alert("Missing statusId in URL");
|
||||||
|
this.isLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2) Fetch request payload
|
||||||
|
const res = await fetch(`/ItRequestAPI/request/${statusId}`);
|
||||||
|
const ct = res.headers.get('content-type') || '';
|
||||||
|
let data, text;
|
||||||
|
|
||||||
|
if (ct.includes('application/json')) {
|
||||||
|
data = await res.json();
|
||||||
|
} else {
|
||||||
|
text = await res.text();
|
||||||
|
throw new Error(text || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(data?.message || `HTTP ${res.status}`);
|
||||||
|
|
||||||
|
console.log("RequestReview raw payload:", data);
|
||||||
|
|
||||||
|
// 3) Requester / submit metadata
|
||||||
|
// Prefer top-level data.userInfo; if absent, fall back to data.request / data.Request
|
||||||
|
const reqSrc = (data.userInfo ?? data.request ?? data.Request ?? {});
|
||||||
|
|
||||||
|
// Many APIs place submit date under status or request; pick the first available
|
||||||
|
const submittedAt =
|
||||||
|
data.status?.submitDate ?? data.status?.SubmitDate ??
|
||||||
|
reqSrc.submitDate ?? reqSrc.SubmitDate ??
|
||||||
|
data.status?.firstSubmittedAt ?? data.status?.FirstSubmittedAt ??
|
||||||
|
data.request?.firstSubmittedAt ?? data.Request?.FirstSubmittedAt ?? "";
|
||||||
|
|
||||||
|
this.userInfo = {
|
||||||
|
staffName: reqSrc.staffName ?? reqSrc.StaffName ?? "",
|
||||||
|
departmentName: reqSrc.departmentName ?? reqSrc.DepartmentName ?? "",
|
||||||
|
companyName: reqSrc.companyName ?? reqSrc.CompanyName ?? "",
|
||||||
|
designation: reqSrc.designation ?? reqSrc.Designation ?? "",
|
||||||
|
submitDate: submittedAt
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4) Hardware
|
||||||
|
this.hardware = (data.hardware ?? []).map(x => ({
|
||||||
|
id: x.id ?? x.Id,
|
||||||
|
category: x.category ?? x.Category ?? "",
|
||||||
|
purpose: x.purpose ?? x.Purpose ?? "",
|
||||||
|
justification: x.justification ?? x.Justification ?? "",
|
||||||
|
otherDescription: x.otherDescription ?? x.OtherDescription ?? ""
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 5) Emails
|
||||||
|
this.emails = (data.emails ?? []).map(x => ({
|
||||||
|
id: x.id ?? x.Id,
|
||||||
|
purpose: x.purpose ?? x.Purpose ?? "",
|
||||||
|
proposedAddress: x.proposedAddress ?? x.ProposedAddress ?? ""
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 6) OS requirements
|
||||||
|
this.osreqs = (data.osreqs ?? data.OSReqs ?? []).map(x => ({
|
||||||
|
id: x.id ?? x.Id,
|
||||||
|
requirementText: x.requirementText ?? x.RequirementText ?? ""
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 7) Software
|
||||||
|
this.software = (data.software ?? []).map(x => ({
|
||||||
|
id: x.id ?? x.Id,
|
||||||
|
bucket: x.bucket ?? x.Bucket ?? "",
|
||||||
|
name: x.name ?? x.Name ?? "",
|
||||||
|
otherName: x.otherName ?? x.OtherName ?? "",
|
||||||
|
notes: x.notes ?? x.Notes ?? ""
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 8) Shared permissions
|
||||||
|
this.sharedPerms = (data.sharedPerms ?? data.sharedPermissions ?? []).map(x => ({
|
||||||
|
id: x.id ?? x.Id,
|
||||||
|
shareName: x.shareName ?? x.ShareName ?? "",
|
||||||
|
canRead: (x.canRead ?? x.CanRead) || false,
|
||||||
|
canWrite: (x.canWrite ?? x.CanWrite) || false,
|
||||||
|
canDelete: (x.canDelete ?? x.CanDelete) || false,
|
||||||
|
canRemove: (x.canRemove ?? x.CanRemove) || false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 9) Status block
|
||||||
|
this.status = {
|
||||||
|
hodStatus: data.status?.hodStatus ?? data.status?.HodStatus ?? "Pending",
|
||||||
|
gitHodStatus: data.status?.gitHodStatus ?? data.status?.GitHodStatus ?? "Pending",
|
||||||
|
finHodStatus: data.status?.finHodStatus ?? data.status?.FinHodStatus ?? "Pending",
|
||||||
|
mgmtStatus: data.status?.mgmtStatus ?? data.status?.MgmtStatus ?? "Pending",
|
||||||
|
|
||||||
|
hodSubmitDate: data.status?.hodSubmitDate ?? data.status?.HodSubmitDate ?? "",
|
||||||
|
gitHodSubmitDate: data.status?.gitHodSubmitDate ?? data.status?.GitHodSubmitDate ?? "",
|
||||||
|
finHodSubmitDate: data.status?.finHodSubmitDate ?? data.status?.FinHodSubmitDate ?? "",
|
||||||
|
mgmtSubmitDate: data.status?.mgmtSubmitDate ?? data.status?.MgmtSubmitDate ?? "",
|
||||||
|
|
||||||
|
overallStatus: data.status?.overallStatus ?? data.status?.OverallStatus ?? "Pending",
|
||||||
|
canApprove: (data.status?.canApprove ?? data.status?.CanApprove) || false
|
||||||
|
};
|
||||||
|
|
||||||
|
const os = (this.status.overallStatus || '').toLowerCase();
|
||||||
|
if (os === 'cancelled' || os === 'draft') {
|
||||||
|
this.status.canApprove = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("RequestReview fetch failed:", err);
|
||||||
|
alert(`Failed to load request: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
|
||||||
|
async updateStatus(decision) {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const statusId = params.get("statusId");
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/ItRequestAPI/approveReject`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ statusId: parseInt(statusId), decision: decision })
|
||||||
|
});
|
||||||
|
const ct = res.headers.get('content-type') || '';
|
||||||
|
const payload = ct.includes('application/json') ? await res.json() : { message: await res.text() };
|
||||||
|
if (!res.ok) throw new Error(payload?.message || `HTTP ${res.status}`);
|
||||||
|
|
||||||
|
// Small UX pop
|
||||||
|
const verb = decision === 'Approved' ? 'approved' : 'rejected';
|
||||||
|
alert(`Request ${verb} successfully.`);
|
||||||
|
this.loadRequest();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert(`Failed to update status: ${e.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDate(dateStr) {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
try{
|
||||||
|
// Show using local timezone, readable
|
||||||
|
return new Date(dateStr).toLocaleString();
|
||||||
|
}catch{ return dateStr; }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() { this.loadRequest(); }
|
||||||
|
});
|
||||||
|
reviewApp.mount("#reviewApp");
|
||||||
|
</script>
|
||||||
360
Areas/IT/Views/ApprovalDashboard/SectionB.cshtml
Normal file
360
Areas/IT/Views/ApprovalDashboard/SectionB.cshtml
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "IT Section B – Asset Information";
|
||||||
|
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||||
|
// Expect ?statusId=123
|
||||||
|
}
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root{ --card-r:16px; --soft-b:#eef2f6; --soft-s:0 8px 24px rgba(0,0,0,.08); --muted:#6b7280; }
|
||||||
|
#bApp{ max-width:1100px; margin:auto; font-size:14px; }
|
||||||
|
.ui-card{ background:#fff; border:1px solid var(--soft-b); border-radius:var(--card-r); box-shadow:var(--soft-s); margin-bottom:16px; overflow:hidden; }
|
||||||
|
.ui-head{ display:flex; align-items:center; justify-content:space-between; padding:14px 18px; border-bottom:1px solid var(--soft-b); background:linear-gradient(180deg,#fbfdff,#f7fafc); }
|
||||||
|
.ui-head h6{ margin:0; font-weight:800; color:#0b5ed7; }
|
||||||
|
.ui-body{ padding:16px 18px; }
|
||||||
|
.muted{ color:var(--muted); }
|
||||||
|
.grid2{ display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||||
|
@@media (max-width:768px){ .grid2{ grid-template-columns:1fr; } }
|
||||||
|
.readonly-pill{ display:inline-flex; align-items:center; gap:.4rem; padding:.28rem .6rem; border-radius:999px; background:#eef2f7; color:#334155; font-weight:700; font-size:12px; }
|
||||||
|
.sig-box{ border:1px dashed #d1d5db; border-radius:8px; padding:12px; background:#fbfbfc; }
|
||||||
|
.submit-bar{ position:sticky; bottom:12px; z-index:5; display:flex; justify-content:flex-end; gap:10px; padding:12px; border-radius:12px; background:rgba(255,255,255,.85); backdrop-filter:blur(6px); border:1px solid var(--soft-b); box-shadow:var(--soft-s); margin:6px 0 30px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="bApp">
|
||||||
|
<!-- Page head -->
|
||||||
|
<div class="d-flex align-items-center justify-content-between my-3">
|
||||||
|
<h5 class="m-0 fw-bold">Section B – Asset Information</h5>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="readonly-pill">Overall: {{ meta.overallStatus || '—' }}</span>
|
||||||
|
<span class="readonly-pill">Requestor: {{ meta.requestorName || '—' }}</span>
|
||||||
|
<span class="readonly-pill" v-if="meta.isItMember">You are IT</span>
|
||||||
|
<span class="readonly-pill" :class="meta.sectionBSent ? '' : 'bg-warning-subtle'">
|
||||||
|
{{ meta.sectionBSent ? 'Sent' : 'Draft' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
|
||||||
|
<div v-if="busy" class="alert alert-secondary py-2">Loading…</div>
|
||||||
|
|
||||||
|
<!-- Gate -->
|
||||||
|
<div v-if="!busy && meta.overallStatus !== 'Approved'" class="alert alert-warning">
|
||||||
|
Section B is available only after the request is <strong>Approved</strong>. Current status: <strong>{{ meta.overallStatus || '—' }}</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="!busy && meta.overallStatus === 'Approved'">
|
||||||
|
<!-- Asset Information -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-head">
|
||||||
|
<h6><i class="bi bi-hdd-stack"></i> Asset Information</h6>
|
||||||
|
<span class="muted" v-if="meta.lastEditedBy">
|
||||||
|
Last edited by {{ meta.lastEditedBy }} at {{ fmtDT(meta.lastEditedAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ui-body">
|
||||||
|
<div v-if="!meta.isItMember" class="alert alert-info py-2">
|
||||||
|
You’re not in the IT Team. You can view once saved, but cannot edit.
|
||||||
|
</div>
|
||||||
|
<div v-if="meta.locked" class="alert alert-light border">
|
||||||
|
Locked for editing (sent). Acceptances can proceed below.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Asset No</label>
|
||||||
|
<input class="form-control" v-model.trim="form.assetNo" :disabled="!meta.isItMember || meta.locked">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Machine ID</label>
|
||||||
|
<input class="form-control" v-model.trim="form.machineId" :disabled="!meta.isItMember || meta.locked">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">IP Address</label>
|
||||||
|
<input class="form-control" v-model.trim="form.ipAddress" :disabled="!meta.isItMember || meta.locked">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Wired MAC Address</label>
|
||||||
|
<input class="form-control" v-model.trim="form.wiredMac" :disabled="!meta.isItMember || meta.locked">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Wi-Fi MAC Address</label>
|
||||||
|
<input class="form-control" v-model.trim="form.wifiMac" :disabled="!meta.isItMember || meta.locked">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="form-label">Dial-up Account</label>
|
||||||
|
<input class="form-control" v-model.trim="form.dialupAcc" :disabled="!meta.isItMember || meta.locked">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="form-label">Remarks</label>
|
||||||
|
<textarea rows="4" class="form-control" v-model.trim="form.remarks" :disabled="!meta.isItMember || meta.locked"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Acceptances -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-head">
|
||||||
|
<h6><i class="bi bi-patch-check"></i> Acceptances</h6>
|
||||||
|
</div>
|
||||||
|
<div class="ui-body">
|
||||||
|
<div v-if="!meta.sectionBSent" class="alert alert-light border">
|
||||||
|
Send Section B first to enable acceptances.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3" v-else>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="sig-box">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<strong>Requestor Acknowledgement</strong>
|
||||||
|
<span class="badge" :class="meta.requestorAccepted ? 'bg-success' : 'bg-secondary'">
|
||||||
|
{{ meta.requestorAccepted ? 'Accepted' : 'Pending' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<div>Name: <strong>{{ meta.requestorName || '—' }}</strong></div>
|
||||||
|
<div>Date: <strong>{{ fmtDT(meta.requestorAcceptedAt) || '—' }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button class="btn btn-outline-primary btn-sm"
|
||||||
|
:disabled="busy || !meta.isRequestor || meta.requestorAccepted"
|
||||||
|
@@click="accept('REQUESTOR')">
|
||||||
|
I Accept
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="sig-box">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<strong>Completed by (IT)</strong>
|
||||||
|
<span class="badge" :class="meta.itAccepted ? 'bg-success' : 'bg-secondary'">
|
||||||
|
{{ meta.itAccepted ? 'Accepted' : 'Pending' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<div>Name: <strong>{{ meta.itAcceptedBy || (meta.isItMember ? '(you?)' : '—') }}</strong></div>
|
||||||
|
<div>Date: <strong>{{ fmtDT(meta.itAcceptedAt) || '—' }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button class="btn btn-outline-primary btn-sm"
|
||||||
|
:disabled="busy || !meta.isItMember || meta.itAccepted"
|
||||||
|
@@click="accept('IT')">
|
||||||
|
IT Accept
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-outline-secondary btn-sm mt-2"
|
||||||
|
:disabled="busy || !(meta.requestorAccepted && meta.itAccepted)"
|
||||||
|
@@click="downloadPdf">
|
||||||
|
Download PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sticky action bar -->
|
||||||
|
<div class="submit-bar">
|
||||||
|
<button class="btn btn-light me-auto" @@click="goBack">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary"
|
||||||
|
:disabled="busy || !meta.isItMember || meta.locked"
|
||||||
|
@@click="resetDraft">
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary"
|
||||||
|
:disabled="busy || !meta.isItMember || meta.locked"
|
||||||
|
@@click="saveDraft">
|
||||||
|
<span v-if="saving" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Save Draft
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-success"
|
||||||
|
:disabled="busy || !meta.isItMember || meta.locked"
|
||||||
|
@@click="sendNow">
|
||||||
|
Send Now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts{
|
||||||
|
<script>
|
||||||
|
const bApp = Vue.createApp({
|
||||||
|
data(){
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
return {
|
||||||
|
busy:false, saving:false, error:null,
|
||||||
|
statusId: Number(url.searchParams.get('statusId')) || 0,
|
||||||
|
returnUrl: url.searchParams.get('returnUrl'),
|
||||||
|
meta:{
|
||||||
|
overallStatus:null, requestorName:null,
|
||||||
|
isRequestor:false, isItMember:false,
|
||||||
|
sectionBSent:false, sectionBSentAt:null,
|
||||||
|
locked:false,
|
||||||
|
lastEditedBy:null, lastEditedAt:null,
|
||||||
|
requestorAccepted:false, requestorAcceptedAt:null,
|
||||||
|
itAccepted:false, itAcceptedAt:null, itAcceptedBy:null
|
||||||
|
},
|
||||||
|
form:{ assetNo:"", machineId:"", ipAddress:"", wiredMac:"", wifiMac:"", dialupAcc:"", remarks:"" }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods:{
|
||||||
|
|
||||||
|
goBack() {
|
||||||
|
const go = (u) => window.location.href = u;
|
||||||
|
|
||||||
|
// 1) Prefer returnUrl if it's local
|
||||||
|
if (this.returnUrl) {
|
||||||
|
try {
|
||||||
|
const u = new URL(this.returnUrl, window.location.origin);
|
||||||
|
if (u.origin === window.location.origin) { go(u.href); return; }
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Else use Referer if it's local
|
||||||
|
if (document.referrer) {
|
||||||
|
try {
|
||||||
|
const u = new URL(document.referrer);
|
||||||
|
if (u.origin === window.location.origin) { go(document.referrer); return; }
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Else use history
|
||||||
|
if (history.length > 1) { history.back(); return; }
|
||||||
|
|
||||||
|
// 4) Final hard fallback by role
|
||||||
|
go(this.meta.isItMember ? '/IT/ApprovalDashboard' : '/IT/MyRequests');
|
||||||
|
},
|
||||||
|
|
||||||
|
fmtDT(d){ if(!d) return ""; const dt=new Date(d); return isNaN(dt)?"":dt.toLocaleString(); },
|
||||||
|
async load(){
|
||||||
|
try{
|
||||||
|
this.busy=true; this.error=null;
|
||||||
|
const r = await fetch(`/ItRequestAPI/sectionB/meta?statusId=${this.statusId}`);
|
||||||
|
if(!r.ok) throw new Error(`Load failed (${r.status})`);
|
||||||
|
const j = await r.json();
|
||||||
|
|
||||||
|
this.meta = {
|
||||||
|
overallStatus: j.overallStatus,
|
||||||
|
requestorName: j.requestorName,
|
||||||
|
isRequestor: j.isRequestor,
|
||||||
|
isItMember: j.isItMember,
|
||||||
|
sectionBSent: !!j.sectionBSent,
|
||||||
|
sectionBSentAt: j.sectionBSentAt,
|
||||||
|
locked: !!j.locked,
|
||||||
|
lastEditedBy: j.sectionB?.lastEditedBy || null,
|
||||||
|
lastEditedAt: j.sectionB?.lastEditedAt || null,
|
||||||
|
requestorAccepted: !!j.requestorAccepted,
|
||||||
|
requestorAcceptedAt: j.requestorAcceptedAt || null,
|
||||||
|
itAccepted: !!j.itAccepted,
|
||||||
|
itAcceptedAt: j.itAcceptedAt || null,
|
||||||
|
itAcceptedBy: j.itAcceptedBy || null
|
||||||
|
};
|
||||||
|
|
||||||
|
const s = j.sectionB || {};
|
||||||
|
this.form = {
|
||||||
|
assetNo: s.assetNo || "",
|
||||||
|
machineId: s.machineId || "",
|
||||||
|
ipAddress: s.ipAddress || "",
|
||||||
|
wiredMac: s.wiredMac || "",
|
||||||
|
wifiMac: s.wifiMac || "",
|
||||||
|
dialupAcc: s.dialupAcc || "",
|
||||||
|
remarks: s.remarks || ""
|
||||||
|
};
|
||||||
|
}catch(e){
|
||||||
|
this.error = e.message || 'Failed to load.';
|
||||||
|
}finally{
|
||||||
|
this.busy=false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async saveDraft(){
|
||||||
|
try{
|
||||||
|
this.saving=true; this.error=null;
|
||||||
|
const res = await fetch('/ItRequestAPI/sectionB/save', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ statusId:this.statusId, ...this.form })
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(()=> ({}));
|
||||||
|
if(!res.ok) throw new Error(j.message || `Save failed (${res.status})`);
|
||||||
|
await this.load();
|
||||||
|
}catch(e){ this.error = e.message || 'Unable to save.'; }
|
||||||
|
finally{ this.saving=false; }
|
||||||
|
},
|
||||||
|
async resetDraft(){
|
||||||
|
if(!confirm('Reset Section B to an empty draft?')) return;
|
||||||
|
try{
|
||||||
|
this.busy=true; this.error=null;
|
||||||
|
const res = await fetch('/ItRequestAPI/sectionB/reset', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ statusId:this.statusId })
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(()=> ({}));
|
||||||
|
if(!res.ok) throw new Error(j.message || `Reset failed (${res.status})`);
|
||||||
|
await this.load();
|
||||||
|
}catch(e){ this.error = e.message || 'Unable to reset.'; }
|
||||||
|
finally{ this.busy=false; }
|
||||||
|
},
|
||||||
|
async sendNow() {
|
||||||
|
if (!confirm('Send Section B now? You will not be able to edit afterwards.')) return;
|
||||||
|
|
||||||
|
// optional guard (mirrors server rule)
|
||||||
|
const a = (this.form.assetNo || "").trim(), m = (this.form.machineId || "").trim(), i = (this.form.ipAddress || "").trim();
|
||||||
|
if (!a && !m && !i) { alert('Please provide at least Asset No, Machine ID, or IP Address.'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.busy = true; this.error = null;
|
||||||
|
const res = await fetch('/ItRequestAPI/sectionB/send', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
statusId: this.statusId,
|
||||||
|
assetNo: this.form.assetNo,
|
||||||
|
machineId: this.form.machineId,
|
||||||
|
ipAddress: this.form.ipAddress,
|
||||||
|
wiredMac: this.form.wiredMac,
|
||||||
|
wifiMac: this.form.wifiMac,
|
||||||
|
dialupAcc: this.form.dialupAcc,
|
||||||
|
remarks: this.form.remarks
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(j.message || `Send failed (${res.status})`);
|
||||||
|
alert(j.message || 'Section B sent and locked for editing.');
|
||||||
|
await this.load();
|
||||||
|
} catch (e) {
|
||||||
|
this.error = e.message || 'Unable to send.';
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async accept(kind){
|
||||||
|
try{
|
||||||
|
this.busy=true; this.error=null;
|
||||||
|
const res = await fetch('/ItRequestAPI/sectionB/accept', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ statusId:this.statusId, by: kind })
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(()=> ({}));
|
||||||
|
if(!res.ok) throw new Error(j.message || `Accept failed (${res.status})`);
|
||||||
|
await this.load();
|
||||||
|
}catch(e){ this.error = e.message || 'Action failed.'; }
|
||||||
|
finally{ this.busy=false; }
|
||||||
|
},
|
||||||
|
downloadPdf(){
|
||||||
|
window.open(`/ItRequestAPI/sectionB/pdf?statusId=${this.statusId}`,'_blank');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted(){
|
||||||
|
if(!this.statusId){ this.error='Missing statusId in the URL.'; return; }
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bApp.mount('#bApp');
|
||||||
|
</script>
|
||||||
|
}
|
||||||
321
Areas/IT/Views/ApprovalDashboard/SectionBEdit.cshtml
Normal file
321
Areas/IT/Views/ApprovalDashboard/SectionBEdit.cshtml
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
@{
|
||||||
|
ViewData["Title"] = "Edit Section B – Asset Information";
|
||||||
|
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||||
|
// ?statusId=123
|
||||||
|
}
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root{ --card-r:16px; --soft-b:#eef2f6; --soft-s:0 8px 24px rgba(0,0,0,.08); --muted:#6b7280; }
|
||||||
|
#sbEditApp{ max-width:1100px; margin:auto; font-size:14px; }
|
||||||
|
.ui-card{ background:#fff; border:1px solid var(--soft-b); border-radius:var(--card-r); box-shadow:var(--soft-s); margin-bottom:16px; overflow:hidden; }
|
||||||
|
.ui-head{ display:flex; align-items:center; justify-content:space-between; padding:14px 18px; border-bottom:1px solid var(--soft-b); background:linear-gradient(180deg,#fbfdff,#f7fafc); }
|
||||||
|
.ui-head h6{ margin:0; font-weight:800; color:#0b5ed7; }
|
||||||
|
.ui-body{ padding:16px 18px; }
|
||||||
|
.muted{ color:var(--muted); }
|
||||||
|
.grid2{ display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||||
|
@@media (max-width:768px){ .grid2{ grid-template-columns:1fr; } }
|
||||||
|
.readonly-pill{ display:inline-flex; align-items:center; gap:.4rem; padding:.28rem .6rem; border-radius:999px; background:#eef2f7; color:#334155; font-weight:700; font-size:12px; }
|
||||||
|
.sig-box{ border:1px dashed #d1d5db; border-radius:8px; padding:12px; background:#fbfbfc; }
|
||||||
|
.submit-bar{ position:sticky; bottom:12px; z-index:5; display:flex; justify-content:flex-end; gap:10px; padding:12px; border-radius:12px; background:rgba(255,255,255,.85); backdrop-filter:blur(6px); border:1px solid var(--soft-b); box-shadow:var(--soft-s); margin:6px 0 30px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div id="sbEditApp">
|
||||||
|
<!-- Page head -->
|
||||||
|
<div class="d-flex align-items-center justify-content-between my-3">
|
||||||
|
<h5 class="m-0 fw-bold">Edit Section B – Asset Information</h5>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="readonly-pill">Overall: {{ meta.overallStatus || '—' }}</span>
|
||||||
|
<span class="readonly-pill">Requestor: {{ meta.requestorName || '—' }}</span>
|
||||||
|
<span class="readonly-pill" v-if="meta.isItMember">You are IT</span>
|
||||||
|
<span class="readonly-pill" :class="meta.sectionBSent ? '' : 'bg-warning-subtle'">
|
||||||
|
{{ meta.sectionBSent ? 'Sent' : 'Draft' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
|
||||||
|
<div v-if="busy" class="alert alert-secondary py-2">Loading…</div>
|
||||||
|
|
||||||
|
<!-- Gate -->
|
||||||
|
<div v-if="!busy && meta.overallStatus !== 'Approved'" class="alert alert-warning">
|
||||||
|
Section B is available only after the request is <strong>Approved</strong>. Current status: <strong>{{ meta.overallStatus || '—' }}</strong>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="!busy && meta.overallStatus === 'Approved'">
|
||||||
|
<!-- Asset info -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-head">
|
||||||
|
<h6><i class="bi bi-hdd-stack"></i> Asset Information</h6>
|
||||||
|
<span class="muted" v-if="meta.lastEditedBy">
|
||||||
|
Last edited by {{ meta.lastEditedBy }} at {{ fmtDT(meta.lastEditedAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="ui-body">
|
||||||
|
<div v-if="!meta.isItMember" class="alert alert-info py-2">
|
||||||
|
You’re not in the IT Team. You can view once saved, but cannot edit.
|
||||||
|
</div>
|
||||||
|
<div v-if="meta.locked" class="alert alert-light border">
|
||||||
|
Locked for editing (sent). Acceptances can proceed in Section B page.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid2">
|
||||||
|
<div><label class="form-label">Asset No</label>
|
||||||
|
<input class="form-control" v-model.trim="form.assetNo" :disabled="!meta.isItMember || meta.locked">
|
||||||
|
</div>
|
||||||
|
<div><label class="form-label">Machine ID</label>
|
||||||
|
<input class="form-control" v-model.trim="form.machineId" :disabled="!meta.isItMember || meta.locked">
|
||||||
|
</div>
|
||||||
|
<div><label class="form-label">IP Address</label>
|
||||||
|
<input class="form-control" v-model.trim="form.ipAddress" :disabled="!meta.isItMember || meta.locked">
|
||||||
|
</div>
|
||||||
|
<div><label class="form-label">Wired MAC Address</label>
|
||||||
|
<input class="form-control" v-model.trim="form.wiredMac" :disabled="!meta.isItMember || meta.locked">
|
||||||
|
</div>
|
||||||
|
<div><label class="form-label">Wi-Fi MAC Address</label>
|
||||||
|
<input class="form-control" v-model.trim="form.wifiMac" :disabled="!meta.isItMember || meta.locked">
|
||||||
|
</div>
|
||||||
|
<div><label class="form-label">Dial-up Account</label>
|
||||||
|
<input class="form-control" v-model.trim="form.dialupAcc" :disabled="!meta.isItMember || meta.locked">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="form-label">Remarks</label>
|
||||||
|
<textarea rows="4" class="form-control" v-model.trim="form.remarks" :disabled="!meta.isItMember || meta.locked"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Acceptances (summary + IT accept button, gated by sent) -->
|
||||||
|
<div class="ui-card">
|
||||||
|
<div class="ui-head">
|
||||||
|
<h6><i class="bi bi-patch-check"></i> Acceptances</h6>
|
||||||
|
</div>
|
||||||
|
<div class="ui-body">
|
||||||
|
<div v-if="!meta.sectionBSent" class="alert alert-light border">
|
||||||
|
Send Section B first to enable acceptances.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3" v-else>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="sig-box">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<strong>Requestor Acknowledgement</strong>
|
||||||
|
<span class="badge" :class="meta.requestorAccepted ? 'bg-success' : 'bg-secondary'">
|
||||||
|
{{ meta.requestorAccepted ? 'Accepted' : 'Pending' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<div>Name: <strong>{{ meta.requestorName || '—' }}</strong></div>
|
||||||
|
<div>Date: <strong>{{ fmtDT(meta.requestorAcceptedAt) || '—' }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<a class="btn btn-outline-primary btn-sm"
|
||||||
|
:href="`/IT/ApprovalDashboard/SectionB?statusId=${statusId}`">
|
||||||
|
Open Section B (requestor view)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="sig-box">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<strong>Completed by (IT)</strong>
|
||||||
|
<span class="badge" :class="meta.itAccepted ? 'bg-success' : 'bg-secondary'">
|
||||||
|
{{ meta.itAccepted ? 'Accepted' : 'Pending' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<div>Name: <strong>{{ meta.itAcceptedBy || (meta.isItMember ? '(you?)' : '—') }}</strong></div>
|
||||||
|
<div>Date: <strong>{{ fmtDT(meta.itAcceptedAt) || '—' }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button class="btn btn-outline-primary btn-sm"
|
||||||
|
:disabled="busy || !meta.isItMember || meta.itAccepted"
|
||||||
|
@@click="accept('IT')">
|
||||||
|
IT Accept
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-outline-secondary btn-sm mt-2"
|
||||||
|
:disabled="busy || !(meta.requestorAccepted && meta.itAccepted)"
|
||||||
|
@@click="downloadPdf">
|
||||||
|
Download PDF
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sticky action bar (same layout as Section B) -->
|
||||||
|
<div class="submit-bar">
|
||||||
|
<button class="btn btn-light me-auto" @@click="goBack">
|
||||||
|
<i class="bi bi-arrow-left"></i> Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="btn btn-secondary" :disabled="busy || !meta.isItMember || meta.locked" @@click="resetDraft">Reset</button>
|
||||||
|
<button class="btn btn-primary" :disabled="busy || !meta.isItMember || meta.locked" @@click="saveDraft">
|
||||||
|
<span v-if="saving" class="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
Save Draft
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-success" :disabled="busy || !meta.isItMember || meta.locked" @@click="sendNow">Send Now</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Scripts{
|
||||||
|
<script>
|
||||||
|
const sbEditApp = Vue.createApp({
|
||||||
|
data(){
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
return {
|
||||||
|
statusId: Number(url.searchParams.get('statusId')) || 0,
|
||||||
|
returnUrl: url.searchParams.get('returnUrl'),
|
||||||
|
busy:false, saving:false, error:null,
|
||||||
|
meta:{
|
||||||
|
overallStatus:null, requestorName:null,
|
||||||
|
isRequestor:false, isItMember:false,
|
||||||
|
sectionBSent:false, sectionBSentAt:null,
|
||||||
|
locked:false,
|
||||||
|
lastEditedBy:null, lastEditedAt:null,
|
||||||
|
requestorAccepted:false, requestorAcceptedAt:null,
|
||||||
|
itAccepted:false, itAcceptedAt:null, itAcceptedBy:null
|
||||||
|
},
|
||||||
|
form:{ assetNo:"", machineId:"", ipAddress:"", wiredMac:"", wifiMac:"", dialupAcc:"", remarks:"" }
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods:{
|
||||||
|
|
||||||
|
goBack() {
|
||||||
|
const go = (u) => window.location.href = u;
|
||||||
|
|
||||||
|
// 1) Prefer explicit returnUrl (only if local)
|
||||||
|
if (this.returnUrl) {
|
||||||
|
try {
|
||||||
|
const u = new URL(this.returnUrl, window.location.origin);
|
||||||
|
if (u.origin === window.location.origin) { go(u.href); return; }
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Else use Referer (only if local)
|
||||||
|
if (document.referrer) {
|
||||||
|
try {
|
||||||
|
const u = new URL(document.referrer);
|
||||||
|
if (u.origin === window.location.origin) { go(document.referrer); return; }
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Else browser history
|
||||||
|
if (history.length > 1) { history.back(); return; }
|
||||||
|
|
||||||
|
// 4) Final fallback based on role
|
||||||
|
go(this.meta.isItMember ? '/IT/ApprovalDashboard' : '/IT/MyRequests');
|
||||||
|
},
|
||||||
|
|
||||||
|
fmtDT(d){ if(!d) return ""; const dt=new Date(d); return isNaN(dt)?"":dt.toLocaleString(); },
|
||||||
|
async load(){
|
||||||
|
try{
|
||||||
|
this.busy=true; this.error=null;
|
||||||
|
const r = await fetch(`/ItRequestAPI/sectionB/meta?statusId=${this.statusId}`);
|
||||||
|
if(!r.ok) throw new Error(`Load failed (${r.status})`);
|
||||||
|
const j = await r.json();
|
||||||
|
|
||||||
|
this.meta = {
|
||||||
|
overallStatus: j.overallStatus,
|
||||||
|
requestorName: j.requestorName,
|
||||||
|
isRequestor: j.isRequestor,
|
||||||
|
isItMember: j.isItMember,
|
||||||
|
sectionBSent: !!j.sectionBSent,
|
||||||
|
sectionBSentAt: j.sectionBSentAt,
|
||||||
|
locked: !!j.locked,
|
||||||
|
lastEditedBy: j.sectionB?.lastEditedBy || null,
|
||||||
|
lastEditedAt: j.sectionB?.lastEditedAt || null,
|
||||||
|
requestorAccepted: !!j.requestorAccepted,
|
||||||
|
requestorAcceptedAt: j.requestorAcceptedAt || null,
|
||||||
|
itAccepted: !!j.itAccepted,
|
||||||
|
itAcceptedAt: j.itAcceptedAt || null,
|
||||||
|
itAcceptedBy: j.itAcceptedBy || null
|
||||||
|
};
|
||||||
|
|
||||||
|
const s = j.sectionB || {};
|
||||||
|
this.form = {
|
||||||
|
assetNo: s.assetNo || "",
|
||||||
|
machineId: s.machineId || "",
|
||||||
|
ipAddress: s.ipAddress || "",
|
||||||
|
wiredMac: s.wiredMac || "",
|
||||||
|
wifiMac: s.wifiMac || "",
|
||||||
|
dialupAcc: s.dialupAcc || "",
|
||||||
|
remarks: s.remarks || ""
|
||||||
|
};
|
||||||
|
}catch(e){ this.error = e.message || 'Failed to load.'; }
|
||||||
|
finally{ this.busy=false; }
|
||||||
|
},
|
||||||
|
async saveDraft(){
|
||||||
|
try{
|
||||||
|
this.saving=true; this.error=null;
|
||||||
|
const res = await fetch('/ItRequestAPI/sectionB/save', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ statusId:this.statusId, ...this.form })
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(()=> ({}));
|
||||||
|
if(!res.ok) throw new Error(j.message || `Save failed (${res.status})`);
|
||||||
|
await this.load();
|
||||||
|
}catch(e){ this.error = e.message || 'Unable to save.'; }
|
||||||
|
finally{ this.saving=false; }
|
||||||
|
},
|
||||||
|
async resetDraft(){
|
||||||
|
if(!confirm('Reset Section B to an empty draft?')) return;
|
||||||
|
try{
|
||||||
|
this.busy=true; this.error=null;
|
||||||
|
const res = await fetch('/ItRequestAPI/sectionB/reset', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ statusId:this.statusId })
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(()=> ({}));
|
||||||
|
if(!res.ok) throw new Error(j.message || `Reset failed (${res.status})`);
|
||||||
|
await this.load();
|
||||||
|
}catch(e){ this.error = e.message || 'Unable to reset.'; }
|
||||||
|
finally{ this.busy=false; }
|
||||||
|
},
|
||||||
|
async sendNow(){
|
||||||
|
if(!confirm('Send Section B now? You will not be able to edit afterwards.')) return;
|
||||||
|
try{
|
||||||
|
this.busy=true; this.error=null;
|
||||||
|
const res = await fetch('/ItRequestAPI/sectionB/send', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ statusId:this.statusId })
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(()=> ({}));
|
||||||
|
if(!res.ok) throw new Error(j.message || `Send failed (${res.status})`);
|
||||||
|
alert(j.message || 'Section B sent and locked for editing.');
|
||||||
|
await this.load();
|
||||||
|
}catch(e){ this.error = e.message || 'Unable to send.'; }
|
||||||
|
finally{ this.busy=false; }
|
||||||
|
},
|
||||||
|
async accept(kind){ // IT accept from here
|
||||||
|
try{
|
||||||
|
this.busy=true; this.error=null;
|
||||||
|
const res = await fetch('/ItRequestAPI/sectionB/accept', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ statusId:this.statusId, by: kind })
|
||||||
|
});
|
||||||
|
const j = await res.json().catch(()=> ({}));
|
||||||
|
if(!res.ok) throw new Error(j.message || `Accept failed (${res.status})`);
|
||||||
|
await this.load();
|
||||||
|
}catch(e){ this.error = e.message || 'Action failed.'; }
|
||||||
|
finally{ this.busy=false; }
|
||||||
|
},
|
||||||
|
downloadPdf(){ window.open(`/ItRequestAPI/sectionB/pdf?statusId=${this.statusId}`,'_blank'); }
|
||||||
|
},
|
||||||
|
mounted(){
|
||||||
|
if(!this.statusId){ this.error='Missing statusId in the URL.'; return; }
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sbEditApp.mount('#sbEditApp');
|
||||||
|
</script>
|
||||||
|
}
|
||||||
876
Controllers/API/BookingsAPI.cs
Normal file
876
Controllers/API/BookingsAPI.cs
Normal file
@ -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<BookingsAPIController> _logger;
|
||||||
|
private static readonly TimeZoneInfo AppTz = TimeZoneInfo.FindSystemTimeZoneById("Asia/Kuala_Lumpur");
|
||||||
|
|
||||||
|
public BookingsAPIController(CentralSystemContext db, ILogger<BookingsAPIController> 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<IActionResult> 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<IActionResult> GetManagersAsync()
|
||||||
|
{
|
||||||
|
var ids = await _db.BookingManager
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(x => x.IsActive)
|
||||||
|
.Select(x => x.UserId)
|
||||||
|
.ToListAsync();
|
||||||
|
return Ok(ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SaveManagersDto { public List<int>? UserIds { get; set; } }
|
||||||
|
|
||||||
|
// POST /api/BookingsApi/managers body: { "userIds":[1,2,3] }
|
||||||
|
[HttpPost("managers")]
|
||||||
|
public async Task<IActionResult> 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<int>(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<IActionResult> 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<BookingStatus>(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<BookingStatus>(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<IActionResult> 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<CreateRoomDto>(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<CreateBookingDto>(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<ValidationResult>();
|
||||||
|
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<IActionResult> 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<UpdateRoomDto>(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<UpdateBookingDto>(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<ValidationResult>();
|
||||||
|
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<IActionResult> 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." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1812
Controllers/API/ITRequestAPI.cs
Normal file
1812
Controllers/API/ITRequestAPI.cs
Normal file
File diff suppressed because it is too large
Load Diff
@ -7,6 +7,9 @@ using PSTW_CentralSystem.Areas.Inventory.Models;
|
|||||||
using PSTW_CentralSystem.Areas.OTcalculate.Models;
|
using PSTW_CentralSystem.Areas.OTcalculate.Models;
|
||||||
using PSTW_CentralSystem.Models;
|
using PSTW_CentralSystem.Models;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using PSTW_CentralSystem.Areas.Bookings.Models;
|
||||||
|
using PSTW_CentralSystem.Areas.IT.Models;
|
||||||
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.DBContext
|
namespace PSTW_CentralSystem.DBContext
|
||||||
{
|
{
|
||||||
@ -110,6 +113,31 @@ namespace PSTW_CentralSystem.DBContext
|
|||||||
public DbSet<ApprovalFlowModel> Approvalflow { get; set; }
|
public DbSet<ApprovalFlowModel> Approvalflow { get; set; }
|
||||||
public DbSet<StaffSignModel> Staffsign { get; set; }
|
public DbSet<StaffSignModel> Staffsign { get; set; }
|
||||||
|
|
||||||
|
// Bookings
|
||||||
|
public DbSet<Booking> Bookings { get; set; }
|
||||||
|
public DbSet<Room> Rooms { get; set; }
|
||||||
|
public DbSet<BookingManager> BookingManager { get; set; }
|
||||||
|
|
||||||
|
// ItForm
|
||||||
|
public DbSet<ItRequest> ItRequests { get; set; }
|
||||||
|
public DbSet<ItRequestHardware> ItRequestHardwares { get; set; }
|
||||||
|
public DbSet<ItRequestEmail> ItRequestEmails { get; set; }
|
||||||
|
public DbSet<ItRequestOsRequirement> ItRequestOsRequirement { get; set; }
|
||||||
|
public DbSet<ItRequestSoftware> ItRequestSoftware { get; set; }
|
||||||
|
public DbSet<ItRequestSharedPermission> ItRequestSharedPermission { get; set; }
|
||||||
|
public DbSet<ItApprovalFlow> ItApprovalFlows { get; set; }
|
||||||
|
public DbSet<ItRequestStatus> ItRequestStatus { get; set; }
|
||||||
|
public DbSet<ItRequestAssetInfo> ItRequestAssetInfo { get; set; }
|
||||||
|
public DbSet<ItTeamMember> ItTeamMembers { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//testingvhjbnadgfsbgdngffdfdsdfdgfdfdg
|
//testingvhjbnadgfsbgdngffdfdsdfdgfdfdg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -582,6 +582,65 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li class="sidebar-item">
|
||||||
|
<a class="sidebar-link has-arrow waves-effect waves-dark"
|
||||||
|
href="javascript:void(0)"
|
||||||
|
aria-expanded="false">
|
||||||
|
<i class="mdi mdi-receipt"></i><span class="hide-menu">Rooms Booking</span>
|
||||||
|
</a>
|
||||||
|
<ul aria-expanded="false" class="collapse first-level">
|
||||||
|
<li class="sidebar-item">
|
||||||
|
<a class="sidebar-link waves-effect waves-dark sidebar-link" asp-area="Bookings" asp-controller="Bookings" asp-action="Managers" aria-expanded="false">
|
||||||
|
<i class="mdi mdi-view-dashboard"></i><span class="hide-menu">Assign</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-item">
|
||||||
|
<a class="sidebar-link waves-effect waves-dark sidebar-link" asp-area="Bookings" asp-controller="Bookings" asp-action="Index" aria-expanded="false">
|
||||||
|
<i class="mdi mdi-view-dashboard"></i><span class="hide-menu">List</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-item">
|
||||||
|
<a class="sidebar-link waves-effect waves-dark sidebar-link" asp-area="Bookings" asp-controller="Bookings" asp-action="Calendar" aria-expanded="false">
|
||||||
|
<i class="mdi mdi-view-dashboard"></i><span class="hide-menu">Calendar</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-item">
|
||||||
|
<a class="sidebar-link waves-effect waves-dark sidebar-link" asp-area="Bookings" asp-controller="Bookings" asp-action="Room" aria-expanded="false">
|
||||||
|
<i class="mdi mdi-view-dashboard"></i><span class="hide-menu">Rooms</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-item">
|
||||||
|
<a class="sidebar-link has-arrow waves-effect waves-dark"
|
||||||
|
href="javascript:void(0)"
|
||||||
|
aria-expanded="false">
|
||||||
|
<i class="mdi mdi-receipt"></i><span class="hide-menu">IT Request Form</span>
|
||||||
|
</a>
|
||||||
|
<ul aria-expanded="false" class="collapse first-level">
|
||||||
|
<li class="sidebar-item">
|
||||||
|
<a class="sidebar-link waves-effect waves-dark sidebar-link" asp-area="IT" asp-controller="ApprovalDashboard" asp-action="Admin" aria-expanded="false">
|
||||||
|
<i class="mdi mdi-view-dashboard"></i><span class="hide-menu">Assignings</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-item">
|
||||||
|
<a class="sidebar-link waves-effect waves-dark sidebar-link" asp-area="IT" asp-controller="ApprovalDashboard" asp-action="Create" aria-expanded="false">
|
||||||
|
<i class="mdi mdi-view-dashboard"></i><span class="hide-menu">Registeration</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-item">
|
||||||
|
<a class="sidebar-link waves-effect waves-dark sidebar-link" asp-area="IT" asp-controller="ApprovalDashboard" asp-action="MyRequests" aria-expanded="false">
|
||||||
|
<i class="mdi mdi-view-dashboard"></i><span class="hide-menu">My Requests</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-item">
|
||||||
|
<a class="sidebar-link waves-effect waves-dark sidebar-link" asp-area="IT" asp-controller="ApprovalDashboard" asp-action="Approval" aria-expanded="false">
|
||||||
|
<i class="mdi mdi-view-dashboard"></i><span class="hide-menu">Approval</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
<!-- <li class="sidebar-item">
|
<!-- <li class="sidebar-item">
|
||||||
<a class="sidebar-link waves-effect waves-dark sidebar-link"
|
<a class="sidebar-link waves-effect waves-dark sidebar-link"
|
||||||
href="charts.html"
|
href="charts.html"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user