Compare commits
No commits in common. "harris" and "main" have entirely different histories.
@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"isRoot": true,
|
|
||||||
"tools": {}
|
|
||||||
}
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@ -360,7 +360,4 @@ MigrationBackup/
|
|||||||
.ionide/
|
.ionide/
|
||||||
|
|
||||||
# Fody - auto-generated XML schema
|
# Fody - auto-generated XML schema
|
||||||
FodyWeavers.xsd
|
FodyWeavers.xsd
|
||||||
|
|
||||||
# Ignore local publish test builds
|
|
||||||
publish-test/
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,721 +0,0 @@
|
|||||||
@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>
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,541 +0,0 @@
|
|||||||
@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>
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,861 +0,0 @@
|
|||||||
@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>
|
|
||||||
}
|
|
||||||
@ -1,227 +0,0 @@
|
|||||||
@{
|
|
||||||
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>
|
|
||||||
@ -1,268 +0,0 @@
|
|||||||
@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>
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,730 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,473 +0,0 @@
|
|||||||
@{
|
|
||||||
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>
|
|
||||||
@ -1,475 +0,0 @@
|
|||||||
@{
|
|
||||||
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>
|
|
||||||
@ -1,825 +0,0 @@
|
|||||||
@{
|
|
||||||
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>
|
|
||||||
}
|
|
||||||
@ -1,698 +0,0 @@
|
|||||||
@{
|
|
||||||
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>
|
|
||||||
}
|
|
||||||
@ -1,557 +0,0 @@
|
|||||||
@{
|
|
||||||
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>
|
|
||||||
}
|
|
||||||
@ -1,632 +0,0 @@
|
|||||||
@{
|
|
||||||
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>
|
|
||||||
@ -1,360 +0,0 @@
|
|||||||
@{
|
|
||||||
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>
|
|
||||||
}
|
|
||||||
@ -1,321 +0,0 @@
|
|||||||
@{
|
|
||||||
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>
|
|
||||||
}
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
@page
|
|
||||||
@model AccessDeniedModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Access denied";
|
|
||||||
@inject UserManager<UserModel> _userManager
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user != null)
|
|
||||||
{
|
|
||||||
var userComDept = user.departmentId;
|
|
||||||
var userRole = await _userManager.GetRolesAsync(user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<header id="deniedHeader">
|
|
||||||
<template v-if="ldapUserInfo.role.length == 0"><p class="text-danger">You do not have access to this resource because you have no role. Please contact the system administrator.</p></template>
|
|
||||||
<template v-else><p class="text-danger">You do not have access to this resource.</p></template>
|
|
||||||
</header>
|
|
||||||
@section Scripts {
|
|
||||||
<script>
|
|
||||||
if (typeof jQuery === 'undefined') {
|
|
||||||
console.error('jQuery is not loaded.');
|
|
||||||
}
|
|
||||||
$(function () {
|
|
||||||
app.mount('#deniedHeader');
|
|
||||||
});
|
|
||||||
|
|
||||||
const app = Vue.createApp({
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
ldapUserInfo: {
|
|
||||||
role: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
this.getUserInfo();
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async getUserInfo() {
|
|
||||||
try {
|
|
||||||
// Show the loading modal
|
|
||||||
$('#loadingModal').modal('show');
|
|
||||||
|
|
||||||
// Perform the fetch request
|
|
||||||
const response = await fetch('/IdentityAPI/GetUserInformation', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if the response is OK
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.userInfo) {
|
|
||||||
console.log(data.userInfo)
|
|
||||||
this.ldapUserInfo = data.userInfo
|
|
||||||
} else {
|
|
||||||
console.error('Get user failed:', data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Error getting user information:', error);
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
await new Promise(resolve => {
|
|
||||||
$('#loadingModal').on('shown.bs.modal', resolve);
|
|
||||||
});
|
|
||||||
$('#loadingModal').modal('hide');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public class AccessDeniedModel : PageModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public void OnGet()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
@page
|
|
||||||
@model ConfirmEmailModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Confirm email";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h1>@ViewData["Title"]</h1>
|
|
||||||
<partial name="_StatusMessage" model="Model.StatusMessage" />
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
{
|
|
||||||
public class ConfirmEmailModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
|
|
||||||
public ConfirmEmailModel(UserManager<UserModel> userManager)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string StatusMessage { get; set; }
|
|
||||||
public async Task<IActionResult> OnGetAsync(string userId, string code)
|
|
||||||
{
|
|
||||||
if (userId == null || code == null)
|
|
||||||
{
|
|
||||||
return RedirectToPage("/Index");
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = await _userManager.FindByIdAsync(userId);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{userId}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
|
|
||||||
var result = await _userManager.ConfirmEmailAsync(user, code);
|
|
||||||
StatusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email.";
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
@page
|
|
||||||
@model ConfirmEmailChangeModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Confirm email change";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h1>@ViewData["Title"]</h1>
|
|
||||||
<partial name="_StatusMessage" model="Model.StatusMessage" />
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
{
|
|
||||||
public class ConfirmEmailChangeModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly SignInManager<UserModel> _signInManager;
|
|
||||||
|
|
||||||
public ConfirmEmailChangeModel(UserManager<UserModel> userManager, SignInManager<UserModel> signInManager)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_signInManager = signInManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string StatusMessage { get; set; }
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync(string userId, string email, string code)
|
|
||||||
{
|
|
||||||
if (userId == null || email == null || code == null)
|
|
||||||
{
|
|
||||||
return RedirectToPage("/Index");
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = await _userManager.FindByIdAsync(userId);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{userId}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
|
|
||||||
var result = await _userManager.ChangeEmailAsync(user, email, code);
|
|
||||||
if (!result.Succeeded)
|
|
||||||
{
|
|
||||||
StatusMessage = "Error changing email.";
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
// In our UI email and user name are one and the same, so when we update the email
|
|
||||||
// we need to update the user name.
|
|
||||||
var setUserNameResult = await _userManager.SetUserNameAsync(user, email);
|
|
||||||
if (!setUserNameResult.Succeeded)
|
|
||||||
{
|
|
||||||
StatusMessage = "Error changing user name.";
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _signInManager.RefreshSignInAsync(user);
|
|
||||||
StatusMessage = "Thank you for confirming your email change.";
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
@page
|
|
||||||
@model ExternalLoginModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Register";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h1>@ViewData["Title"]</h1>
|
|
||||||
<h2 id="external-login-title">Associate your @Model.ProviderDisplayName account.</h2>
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
<p id="external-login-description" class="text-info">
|
|
||||||
You've successfully authenticated with <strong>@Model.ProviderDisplayName</strong>.
|
|
||||||
Please enter an email address for this site below and click the Register button to finish
|
|
||||||
logging in.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<form asp-page-handler="Confirmation" asp-route-returnUrl="@Model.ReturnUrl" method="post">
|
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.Email" class="form-control" autocomplete="email" placeholder="Please enter your email."/>
|
|
||||||
<label asp-for="Input.Email" class="form-label"></label>
|
|
||||||
<span asp-validation-for="Input.Email" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
@ -1,224 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Security.Claims;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Encodings.Web;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Identity.UI.Services;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
{
|
|
||||||
[AllowAnonymous]
|
|
||||||
public class ExternalLoginModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly SignInManager<UserModel> _signInManager;
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly IUserStore<UserModel> _userStore;
|
|
||||||
private readonly IUserEmailStore<UserModel> _emailStore;
|
|
||||||
private readonly IEmailSender _emailSender;
|
|
||||||
private readonly ILogger<ExternalLoginModel> _logger;
|
|
||||||
|
|
||||||
public ExternalLoginModel(
|
|
||||||
SignInManager<UserModel> signInManager,
|
|
||||||
UserManager<UserModel> userManager,
|
|
||||||
IUserStore<UserModel> userStore,
|
|
||||||
ILogger<ExternalLoginModel> logger,
|
|
||||||
IEmailSender emailSender)
|
|
||||||
{
|
|
||||||
_signInManager = signInManager;
|
|
||||||
_userManager = userManager;
|
|
||||||
_userStore = userStore;
|
|
||||||
_emailStore = GetEmailStore();
|
|
||||||
_logger = logger;
|
|
||||||
_emailSender = emailSender;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[BindProperty]
|
|
||||||
public InputModel Input { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public string ProviderDisplayName { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public string ReturnUrl { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string ErrorMessage { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public class InputModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[EmailAddress]
|
|
||||||
public string Email { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public IActionResult OnGet() => RedirectToPage("./Login");
|
|
||||||
|
|
||||||
public IActionResult OnPost(string provider, string returnUrl = null)
|
|
||||||
{
|
|
||||||
// Request a redirect to the external login provider.
|
|
||||||
var redirectUrl = Url.Page("./ExternalLogin", pageHandler: "Callback", values: new { returnUrl });
|
|
||||||
var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
|
|
||||||
return new ChallengeResult(provider, properties);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetCallbackAsync(string returnUrl = null, string remoteError = null)
|
|
||||||
{
|
|
||||||
returnUrl = returnUrl ?? Url.Content("~/");
|
|
||||||
if (remoteError != null)
|
|
||||||
{
|
|
||||||
ErrorMessage = $"Error from external provider: {remoteError}";
|
|
||||||
return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
|
|
||||||
}
|
|
||||||
var info = await _signInManager.GetExternalLoginInfoAsync();
|
|
||||||
if (info == null)
|
|
||||||
{
|
|
||||||
ErrorMessage = "Error loading external login information.";
|
|
||||||
return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sign in the user with this external login provider if the user already has a login.
|
|
||||||
var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("{CompanyName} logged in with {LoginProvider} provider.", info.Principal.Identity.Name, info.LoginProvider);
|
|
||||||
return LocalRedirect(returnUrl);
|
|
||||||
}
|
|
||||||
if (result.IsLockedOut)
|
|
||||||
{
|
|
||||||
return RedirectToPage("./Lockout");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// If the user does not have an account, then ask the user to create an account.
|
|
||||||
ReturnUrl = returnUrl;
|
|
||||||
ProviderDisplayName = info.ProviderDisplayName;
|
|
||||||
if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
|
|
||||||
{
|
|
||||||
Input = new InputModel
|
|
||||||
{
|
|
||||||
Email = info.Principal.FindFirstValue(ClaimTypes.Email)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
|
|
||||||
{
|
|
||||||
returnUrl = returnUrl ?? Url.Content("~/");
|
|
||||||
// Get the information about the user from the external login provider
|
|
||||||
var info = await _signInManager.GetExternalLoginInfoAsync();
|
|
||||||
if (info == null)
|
|
||||||
{
|
|
||||||
ErrorMessage = "Error loading external login information during confirmation.";
|
|
||||||
return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ModelState.IsValid)
|
|
||||||
{
|
|
||||||
var user = CreateUser();
|
|
||||||
|
|
||||||
await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
|
|
||||||
await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
|
|
||||||
|
|
||||||
var result = await _userManager.CreateAsync(user);
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
result = await _userManager.AddLoginAsync(user, info);
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("User created an account using {CompanyName} provider.", info.LoginProvider);
|
|
||||||
|
|
||||||
var userId = await _userManager.GetUserIdAsync(user);
|
|
||||||
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
|
||||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
|
||||||
var callbackUrl = Url.Page(
|
|
||||||
"/Account/ConfirmEmail",
|
|
||||||
pageHandler: null,
|
|
||||||
values: new { area = "Identity", userId = userId, code = code },
|
|
||||||
protocol: Request.Scheme);
|
|
||||||
|
|
||||||
await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
|
|
||||||
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
|
|
||||||
|
|
||||||
// If account confirmation is required, we need to show the link if we don't have a real email sender
|
|
||||||
if (_userManager.Options.SignIn.RequireConfirmedAccount)
|
|
||||||
{
|
|
||||||
return RedirectToPage("./RegisterConfirmation", new { Email = Input.Email });
|
|
||||||
}
|
|
||||||
|
|
||||||
await _signInManager.SignInAsync(user, isPersistent: false, info.LoginProvider);
|
|
||||||
return LocalRedirect(returnUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach (var error in result.Errors)
|
|
||||||
{
|
|
||||||
ModelState.AddModelError(string.Empty, error.Description);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ProviderDisplayName = info.ProviderDisplayName;
|
|
||||||
ReturnUrl = returnUrl;
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
private UserModel CreateUser()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return Activator.CreateInstance<UserModel>();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Can't create an instance of '{nameof(UserModel)}'. " +
|
|
||||||
$"Ensure that '{nameof(UserModel)}' is not an abstract class and has a parameterless constructor, or alternatively " +
|
|
||||||
$"override the external login page in /Areas/Identity/Pages/Account/ExternalLogin.cshtml");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private IUserEmailStore<UserModel> GetEmailStore()
|
|
||||||
{
|
|
||||||
if (!_userManager.SupportsUserEmail)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException("The default UI requires a user store with email support.");
|
|
||||||
}
|
|
||||||
return (IUserEmailStore<UserModel>)_userStore;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
@page
|
|
||||||
@model ForgotPasswordModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Forgot your password?";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h2>Enter your email.</h2>
|
|
||||||
<hr />
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<form method="post">
|
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
|
|
||||||
<label asp-for="Input.Email" class="form-label"></label>
|
|
||||||
<span asp-validation-for="Input.Email" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Reset Password</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Encodings.Web;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Identity.UI.Services;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
{
|
|
||||||
public class ForgotPasswordModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly IEmailSender _emailSender;
|
|
||||||
|
|
||||||
public ForgotPasswordModel(UserManager<UserModel> userManager, IEmailSender emailSender)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_emailSender = emailSender;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[BindProperty]
|
|
||||||
public InputModel Input { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public class InputModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[EmailAddress]
|
|
||||||
public string Email { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
|
||||||
{
|
|
||||||
if (ModelState.IsValid)
|
|
||||||
{
|
|
||||||
var user = await _userManager.FindByEmailAsync(Input.Email);
|
|
||||||
if (user == null || !(await _userManager.IsEmailConfirmedAsync(user)))
|
|
||||||
{
|
|
||||||
// Don't reveal that the user does not exist or is not confirmed
|
|
||||||
return RedirectToPage("./ForgotPasswordConfirmation");
|
|
||||||
}
|
|
||||||
|
|
||||||
// For more information on how to enable account confirmation and password reset please
|
|
||||||
// visit https://go.microsoft.com/fwlink/?LinkID=532713
|
|
||||||
var code = await _userManager.GeneratePasswordResetTokenAsync(user);
|
|
||||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
|
||||||
var callbackUrl = Url.Page(
|
|
||||||
"/Account/ResetPassword",
|
|
||||||
pageHandler: null,
|
|
||||||
values: new { area = "Identity", code },
|
|
||||||
protocol: Request.Scheme);
|
|
||||||
|
|
||||||
await _emailSender.SendEmailAsync(
|
|
||||||
Input.Email,
|
|
||||||
"Reset Password",
|
|
||||||
$"Please reset your password by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
|
|
||||||
|
|
||||||
return RedirectToPage("./ForgotPasswordConfirmation");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
@page
|
|
||||||
@model ForgotPasswordConfirmation
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Forgot password confirmation";
|
|
||||||
}
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Please check your email to reset your password.
|
|
||||||
</p>
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[AllowAnonymous]
|
|
||||||
public class ForgotPasswordConfirmation : PageModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public void OnGet()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
@page
|
|
||||||
@model LockoutModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Locked out";
|
|
||||||
}
|
|
||||||
|
|
||||||
<header>
|
|
||||||
<h1 class="text-danger">@ViewData["Title"]</h1>
|
|
||||||
<p class="text-danger">This account has been locked out, please try again later.</p>
|
|
||||||
</header>
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[AllowAnonymous]
|
|
||||||
public class LockoutModel : PageModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public void OnGet()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,188 +0,0 @@
|
|||||||
@page
|
|
||||||
@model LoginModel
|
|
||||||
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Log in";
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="row" id="systemLogin">
|
|
||||||
<div class="row">
|
|
||||||
<h2><label class="col-md-2">Login Type</label></h2>
|
|
||||||
<div class="btn-group col-md-4" role="group" aria-label="Login type">
|
|
||||||
<input type="radio" class="btn-check" name="loginType" id="local-login" value="Local" v-model="loginType">
|
|
||||||
<label class="btn btn-outline-primary" for="local-login">Local</label>
|
|
||||||
|
|
||||||
<input type="radio" class="btn-check" name="loginType" id="ad-login" value="AD" v-model="loginType" checked>
|
|
||||||
<label class="btn btn-outline-primary" for="ad-login">AD</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4" v-if="loginType == 'Local'">
|
|
||||||
<form id="account" method="post">
|
|
||||||
<h2>Use a local account to log in.</h2>
|
|
||||||
<hr />
|
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
|
|
||||||
<label asp-for="Input.Email" class="form-label">Email</label>
|
|
||||||
<span asp-validation-for="Input.Email" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="password" />
|
|
||||||
<label asp-for="Input.Password" class="form-label">Password</label>
|
|
||||||
<span asp-validation-for="Input.Password" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button id="login-submit" type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4" v-if="loginType == 'AD'">
|
|
||||||
<form v-on:submit.prevent="ldapLogin" id="login" method="post">
|
|
||||||
<h2>Use a AD account to log in.</h2>
|
|
||||||
<hr />
|
|
||||||
<div class="text-danger" role="alert"></div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input v-model="ldapLoginInfo.username" id="ldapUsername" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
|
|
||||||
<label id="ldapEmailLabel" class="form-label">Windows Login</label>
|
|
||||||
<span id="ldapEmailError" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input v-model="ldapLoginInfo.password" id="ldapPassword" class="form-control" type="password" autocomplete="current-password" aria-required="true" placeholder="password" />
|
|
||||||
<label id="ldapPasswordLabel" class="form-label">Password</label>
|
|
||||||
<span id="ldapPasswordError" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button id="ldap-login-submit" type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 col-md-offset-2">
|
|
||||||
<section>
|
|
||||||
<h3>Use another service to log in.</h3>
|
|
||||||
<hr />
|
|
||||||
@{
|
|
||||||
if ((Model.ExternalLogins?.Count ?? 0) == 0)
|
|
||||||
{
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
There are no external authentication services configured. See this <a href="https://go.microsoft.com/fwlink/?LinkID=532715">article
|
|
||||||
about setting up this ASP.NET application to support logging in via external services</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<form id="external-account" asp-page="./ExternalLogin" asp-route-returnUrl="@Model.ReturnUrl" method="post" class="form-horizontal">
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
@foreach (var provider in Model.ExternalLogins!)
|
|
||||||
{
|
|
||||||
<button type="submit" class="btn btn-primary" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
|
|
||||||
<script>
|
|
||||||
|
|
||||||
$(function () {
|
|
||||||
app.mount('#systemLogin');
|
|
||||||
|
|
||||||
$('.closeModal').on('click', function () {
|
|
||||||
$('.modal').modal('hide');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const app = Vue.createApp({
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
loginType: 'AD',
|
|
||||||
ldapLoginInfo: {
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
|
|
||||||
},
|
|
||||||
watch: {
|
|
||||||
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async ldapLogin() {
|
|
||||||
try {
|
|
||||||
// Show the loading modal
|
|
||||||
$('#loadingModal').modal('show');
|
|
||||||
|
|
||||||
// Perform the fetch request
|
|
||||||
const response = await fetch('/IdentityAPI/LdapLogin', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(this.ldapLoginInfo),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if the response is OK
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json();
|
|
||||||
throw new Error(errorData.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Redirect if a URL is provided
|
|
||||||
if (data.redirectUrl) {
|
|
||||||
window.location.href = data.redirectUrl;
|
|
||||||
} else {
|
|
||||||
console.error('Login failed:', data);
|
|
||||||
alert('Login failed.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
console.error('Error during LDAP login:', error);
|
|
||||||
alert(error.message);
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
await new Promise(resolve => {
|
|
||||||
$('#loadingModal').on('shown.bs.modal', resolve);
|
|
||||||
});
|
|
||||||
$('#loadingModal').modal('hide');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async fetchControllerMethodList() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/AdminAPI/GetListClassAndMethodInformation', {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! Status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
// Assign data if it exists
|
|
||||||
if (data) {
|
|
||||||
this.controllerMethodData = data;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('There was a problem with the fetch operation:', error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
}
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Identity.UI.Services;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
{
|
|
||||||
public class LoginModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly SignInManager<UserModel> _signInManager;
|
|
||||||
private readonly ILogger<LoginModel> _logger;
|
|
||||||
|
|
||||||
public LoginModel(SignInManager<UserModel> signInManager, ILogger<LoginModel> logger)
|
|
||||||
{
|
|
||||||
_signInManager = signInManager;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[BindProperty]
|
|
||||||
public InputModel Input { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public IList<AuthenticationScheme> ExternalLogins { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public string ReturnUrl { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string ErrorMessage { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public class InputModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[EmailAddress]
|
|
||||||
public string Email { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[DataType(DataType.Password)]
|
|
||||||
public string Password { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Display(Name = "Remember me?")]
|
|
||||||
public bool RememberMe { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task OnGetAsync(string returnUrl = null)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrEmpty(ErrorMessage))
|
|
||||||
{
|
|
||||||
ModelState.AddModelError(string.Empty, ErrorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
returnUrl ??= Url.Content("~/");
|
|
||||||
|
|
||||||
// Clear the existing external cookie to ensure a clean login process
|
|
||||||
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
|
|
||||||
|
|
||||||
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
|
|
||||||
|
|
||||||
ReturnUrl = returnUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
|
|
||||||
{
|
|
||||||
returnUrl ??= Url.Content("~/");
|
|
||||||
|
|
||||||
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
|
|
||||||
|
|
||||||
if (ModelState.IsValid)
|
|
||||||
{
|
|
||||||
// This doesn't count login failures towards account lockout
|
|
||||||
// To enable password failures to trigger account lockout, set lockoutOnFailure: true
|
|
||||||
var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false);
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("User logged in.");
|
|
||||||
return LocalRedirect(returnUrl);
|
|
||||||
}
|
|
||||||
if (result.RequiresTwoFactor)
|
|
||||||
{
|
|
||||||
return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
|
|
||||||
}
|
|
||||||
if (result.IsLockedOut)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("User account locked out.");
|
|
||||||
return RedirectToPage("./Lockout");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we got this far, something failed, redisplay form
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
@page
|
|
||||||
@model LoginWith2faModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Two-factor authentication";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h1>@ViewData["Title"]</h1>
|
|
||||||
<hr />
|
|
||||||
<p>Your login is protected with an authenticator app. Enter your authenticator code below.</p>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<form method="post" asp-route-returnUrl="@Model.ReturnUrl">
|
|
||||||
<input asp-for="RememberMe" type="hidden" />
|
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.TwoFactorCode" class="form-control" autocomplete="off" />
|
|
||||||
<label asp-for="Input.TwoFactorCode" class="form-label"></label>
|
|
||||||
<span asp-validation-for="Input.TwoFactorCode" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<div class="checkbox mb-3">
|
|
||||||
<label asp-for="Input.RememberMachine" class="form-label">
|
|
||||||
<input asp-for="Input.RememberMachine" />
|
|
||||||
@Html.DisplayNameFor(m => m.Input.RememberMachine)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p>
|
|
||||||
Don't have access to your authenticator device? You can
|
|
||||||
<a id="recovery-code-login" asp-page="./LoginWithRecoveryCode" asp-route-returnUrl="@Model.ReturnUrl">log in with a recovery code</a>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
@ -1,132 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
{
|
|
||||||
public class LoginWith2faModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly SignInManager<UserModel> _signInManager;
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly ILogger<LoginWith2faModel> _logger;
|
|
||||||
|
|
||||||
public LoginWith2faModel(
|
|
||||||
SignInManager<UserModel> signInManager,
|
|
||||||
UserManager<UserModel> userManager,
|
|
||||||
ILogger<LoginWith2faModel> logger)
|
|
||||||
{
|
|
||||||
_signInManager = signInManager;
|
|
||||||
_userManager = userManager;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[BindProperty]
|
|
||||||
public InputModel Input { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public bool RememberMe { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public string ReturnUrl { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public class InputModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
|
||||||
[DataType(DataType.Text)]
|
|
||||||
[Display(Name = "Authenticator code")]
|
|
||||||
public string TwoFactorCode { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Display(Name = "Remember this machine")]
|
|
||||||
public bool RememberMachine { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync(bool rememberMe, string returnUrl = null)
|
|
||||||
{
|
|
||||||
// Ensure the user has gone through the username & password screen first
|
|
||||||
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
|
|
||||||
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Unable to load two-factor authentication user.");
|
|
||||||
}
|
|
||||||
|
|
||||||
ReturnUrl = returnUrl;
|
|
||||||
RememberMe = rememberMe;
|
|
||||||
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync(bool rememberMe, string returnUrl = null)
|
|
||||||
{
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
{
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
returnUrl = returnUrl ?? Url.Content("~/");
|
|
||||||
|
|
||||||
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Unable to load two-factor authentication user.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var authenticatorCode = Input.TwoFactorCode.Replace(" ", string.Empty).Replace("-", string.Empty);
|
|
||||||
|
|
||||||
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, Input.RememberMachine);
|
|
||||||
|
|
||||||
var userId = await _userManager.GetUserIdAsync(user);
|
|
||||||
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", user.Id);
|
|
||||||
return LocalRedirect(returnUrl);
|
|
||||||
}
|
|
||||||
else if (result.IsLockedOut)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("User with ID '{UserId}' account locked out.", user.Id);
|
|
||||||
return RedirectToPage("./Lockout");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", user.Id);
|
|
||||||
ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
@page
|
|
||||||
@model LoginWithRecoveryCodeModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Recovery code verification";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h1>@ViewData["Title"]</h1>
|
|
||||||
<hr />
|
|
||||||
<p>
|
|
||||||
You have requested to log in with a recovery code. This login will not be remembered until you provide
|
|
||||||
an authenticator app code at log in or disable 2FA and log in again.
|
|
||||||
</p>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<form method="post">
|
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.RecoveryCode" class="form-control" autocomplete="off" placeholder="RecoveryCode" />
|
|
||||||
<label asp-for="Input.RecoveryCode" class="form-label"></label>
|
|
||||||
<span asp-validation-for="Input.RecoveryCode" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
@ -1,113 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
{
|
|
||||||
public class LoginWithRecoveryCodeModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly SignInManager<UserModel> _signInManager;
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly ILogger<LoginWithRecoveryCodeModel> _logger;
|
|
||||||
|
|
||||||
public LoginWithRecoveryCodeModel(
|
|
||||||
SignInManager<UserModel> signInManager,
|
|
||||||
UserManager<UserModel> userManager,
|
|
||||||
ILogger<LoginWithRecoveryCodeModel> logger)
|
|
||||||
{
|
|
||||||
_signInManager = signInManager;
|
|
||||||
_userManager = userManager;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[BindProperty]
|
|
||||||
public InputModel Input { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public string ReturnUrl { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public class InputModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[BindProperty]
|
|
||||||
[Required]
|
|
||||||
[DataType(DataType.Text)]
|
|
||||||
[Display(Name = "Recovery Code")]
|
|
||||||
public string RecoveryCode { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync(string returnUrl = null)
|
|
||||||
{
|
|
||||||
// Ensure the user has gone through the username & password screen first
|
|
||||||
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Unable to load two-factor authentication user.");
|
|
||||||
}
|
|
||||||
|
|
||||||
ReturnUrl = returnUrl;
|
|
||||||
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
|
|
||||||
{
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
{
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Unable to load two-factor authentication user.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty);
|
|
||||||
|
|
||||||
var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
|
|
||||||
|
|
||||||
var userId = await _userManager.GetUserIdAsync(user);
|
|
||||||
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", user.Id);
|
|
||||||
return LocalRedirect(returnUrl ?? Url.Content("~/"));
|
|
||||||
}
|
|
||||||
if (result.IsLockedOut)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("User account locked out.");
|
|
||||||
return RedirectToPage("./Lockout");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", user.Id);
|
|
||||||
ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
@page
|
|
||||||
@model LogoutModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Log out";
|
|
||||||
}
|
|
||||||
|
|
||||||
<header>
|
|
||||||
<h1>@ViewData["Title"]</h1>
|
|
||||||
@{
|
|
||||||
if (User.Identity?.IsAuthenticated ?? false)
|
|
||||||
{
|
|
||||||
<form class="form-inline" asp-area="Identity" asp-page="/Account/Logout" asp-route-returnUrl="@Url.Page("/", new { area = "" })" method="post">
|
|
||||||
<button type="submit" class="nav-link btn btn-link text-dark">Click here to Logout</button>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<p>You have successfully logged out of the application.</p>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</header>
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
{
|
|
||||||
public class LogoutModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly SignInManager<UserModel> _signInManager;
|
|
||||||
private readonly ILogger<LogoutModel> _logger;
|
|
||||||
|
|
||||||
public LogoutModel(SignInManager<UserModel> signInManager, ILogger<LogoutModel> logger)
|
|
||||||
{
|
|
||||||
_signInManager = signInManager;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPost(string returnUrl = null)
|
|
||||||
{
|
|
||||||
await _signInManager.SignOutAsync();
|
|
||||||
_logger.LogInformation("User logged out.");
|
|
||||||
if (returnUrl != null)
|
|
||||||
{
|
|
||||||
return LocalRedirect(returnUrl);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// This needs to be a redirect so that the browser performs a new
|
|
||||||
// request and the identity for the user gets updated.
|
|
||||||
return RedirectToPage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
@page
|
|
||||||
@model ChangePasswordModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Change password";
|
|
||||||
ViewData["ActivePage"] = ManageNavPages.ChangePassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
<h3>@ViewData["Title"]</h3>
|
|
||||||
<partial name="_StatusMessage" for="StatusMessage" />
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<form id="change-password-form" method="post">
|
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.OldPassword" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Please enter your old password." />
|
|
||||||
<label asp-for="Input.OldPassword" class="form-label"></label>
|
|
||||||
<span asp-validation-for="Input.OldPassword" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.NewPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please enter your new password." />
|
|
||||||
<label asp-for="Input.NewPassword" class="form-label"></label>
|
|
||||||
<span asp-validation-for="Input.NewPassword" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your new password."/>
|
|
||||||
<label asp-for="Input.ConfirmPassword" class="form-label"></label>
|
|
||||||
<span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Update password</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
|
||||||
{
|
|
||||||
public class ChangePasswordModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly SignInManager<UserModel> _signInManager;
|
|
||||||
private readonly ILogger<ChangePasswordModel> _logger;
|
|
||||||
|
|
||||||
public ChangePasswordModel(
|
|
||||||
UserManager<UserModel> userManager,
|
|
||||||
SignInManager<UserModel> signInManager,
|
|
||||||
ILogger<ChangePasswordModel> logger)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_signInManager = signInManager;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[BindProperty]
|
|
||||||
public InputModel Input { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string StatusMessage { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public class InputModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[DataType(DataType.Password)]
|
|
||||||
[Display(Name = "Current password")]
|
|
||||||
public string OldPassword { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
|
||||||
[DataType(DataType.Password)]
|
|
||||||
[Display(Name = "New password")]
|
|
||||||
public string NewPassword { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[DataType(DataType.Password)]
|
|
||||||
[Display(Name = "Confirm new password")]
|
|
||||||
[Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
|
|
||||||
public string ConfirmPassword { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasPassword = await _userManager.HasPasswordAsync(user);
|
|
||||||
if (!hasPassword)
|
|
||||||
{
|
|
||||||
return RedirectToPage("./SetPassword");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
|
||||||
{
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
{
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var changePasswordResult = await _userManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword);
|
|
||||||
if (!changePasswordResult.Succeeded)
|
|
||||||
{
|
|
||||||
foreach (var error in changePasswordResult.Errors)
|
|
||||||
{
|
|
||||||
ModelState.AddModelError(string.Empty, error.Description);
|
|
||||||
}
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _signInManager.RefreshSignInAsync(user);
|
|
||||||
_logger.LogInformation("User changed their password successfully.");
|
|
||||||
StatusMessage = "Your password has been changed.";
|
|
||||||
|
|
||||||
return RedirectToPage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
@page
|
|
||||||
@model DeletePersonalDataModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Delete Personal Data";
|
|
||||||
ViewData["ActivePage"] = ManageNavPages.PersonalData;
|
|
||||||
}
|
|
||||||
|
|
||||||
<h3>@ViewData["Title"]</h3>
|
|
||||||
|
|
||||||
<div class="alert alert-warning" role="alert">
|
|
||||||
<p>
|
|
||||||
<strong>Deleting this data will permanently remove your account, and this cannot be recovered.</strong>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<form id="delete-user" method="post">
|
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
|
|
||||||
@if (Model.RequirePassword)
|
|
||||||
{
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Please enter your password." />
|
|
||||||
<label asp-for="Input.Password" class="form-label"></label>
|
|
||||||
<span asp-validation-for="Input.Password" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<button class="w-100 btn btn-lg btn-danger" type="submit">Delete data and close my account</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
|
||||||
{
|
|
||||||
public class DeletePersonalDataModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly SignInManager<UserModel> _signInManager;
|
|
||||||
private readonly ILogger<DeletePersonalDataModel> _logger;
|
|
||||||
|
|
||||||
public DeletePersonalDataModel(
|
|
||||||
UserManager<UserModel> userManager,
|
|
||||||
SignInManager<UserModel> signInManager,
|
|
||||||
ILogger<DeletePersonalDataModel> logger)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_signInManager = signInManager;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[BindProperty]
|
|
||||||
public InputModel Input { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public class InputModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[DataType(DataType.Password)]
|
|
||||||
public string Password { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public bool RequirePassword { get; set; }
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGet()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
RequirePassword = await _userManager.HasPasswordAsync(user);
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
RequirePassword = await _userManager.HasPasswordAsync(user);
|
|
||||||
if (RequirePassword)
|
|
||||||
{
|
|
||||||
if (!await _userManager.CheckPasswordAsync(user, Input.Password))
|
|
||||||
{
|
|
||||||
ModelState.AddModelError(string.Empty, "Incorrect password.");
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _userManager.DeleteAsync(user);
|
|
||||||
var userId = await _userManager.GetUserIdAsync(user);
|
|
||||||
if (!result.Succeeded)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Unexpected error occurred deleting user.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await _signInManager.SignOutAsync();
|
|
||||||
|
|
||||||
_logger.LogInformation("User with ID '{UserId}' deleted themselves.", userId);
|
|
||||||
|
|
||||||
return Redirect("~/");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
@page
|
|
||||||
@model Disable2faModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Disable two-factor authentication (2FA)";
|
|
||||||
ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
|
|
||||||
}
|
|
||||||
|
|
||||||
<partial name="_StatusMessage" for="StatusMessage" />
|
|
||||||
<h3>@ViewData["Title"]</h3>
|
|
||||||
|
|
||||||
<div class="alert alert-warning" role="alert">
|
|
||||||
<p>
|
|
||||||
<strong>This action only disables 2FA.</strong>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Disabling 2FA does not change the keys used in authenticator apps. If you wish to change the key
|
|
||||||
used in an authenticator app you should <a asp-page="./ResetAuthenticator">reset your authenticator keys.</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<form method="post">
|
|
||||||
<button class="btn btn-danger" type="submit">Disable 2FA</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
|
||||||
{
|
|
||||||
public class Disable2faModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly ILogger<Disable2faModel> _logger;
|
|
||||||
|
|
||||||
public Disable2faModel(
|
|
||||||
UserManager<UserModel> userManager,
|
|
||||||
ILogger<Disable2faModel> logger)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string StatusMessage { get; set; }
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGet()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!await _userManager.GetTwoFactorEnabledAsync(user))
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Cannot disable 2FA for user as it's not currently enabled.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var disable2faResult = await _userManager.SetTwoFactorEnabledAsync(user, false);
|
|
||||||
if (!disable2faResult.Succeeded)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Unexpected error occurred disabling 2FA.");
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", _userManager.GetUserId(User));
|
|
||||||
StatusMessage = "2fa has been disabled. You can reenable 2fa when you setup an authenticator app";
|
|
||||||
return RedirectToPage("./TwoFactorAuthentication");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
@page
|
|
||||||
@model DownloadPersonalDataModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Download Your Data";
|
|
||||||
ViewData["ActivePage"] = ManageNavPages.PersonalData;
|
|
||||||
}
|
|
||||||
|
|
||||||
<h3>@ViewData["Title"]</h3>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
|
||||||
{
|
|
||||||
public class DownloadPersonalDataModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly ILogger<DownloadPersonalDataModel> _logger;
|
|
||||||
|
|
||||||
public DownloadPersonalDataModel(
|
|
||||||
UserManager<UserModel> userManager,
|
|
||||||
ILogger<DownloadPersonalDataModel> logger)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public IActionResult OnGet()
|
|
||||||
{
|
|
||||||
return NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("User with ID '{UserId}' asked for their personal data.", _userManager.GetUserId(User));
|
|
||||||
|
|
||||||
// Only include personal data for download
|
|
||||||
var personalData = new Dictionary<string, string>();
|
|
||||||
var personalDataProps = typeof(UserModel).GetProperties().Where(
|
|
||||||
prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute)));
|
|
||||||
foreach (var p in personalDataProps)
|
|
||||||
{
|
|
||||||
personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null");
|
|
||||||
}
|
|
||||||
|
|
||||||
var logins = await _userManager.GetLoginsAsync(user);
|
|
||||||
foreach (var l in logins)
|
|
||||||
{
|
|
||||||
personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
personalData.Add($"Authenticator Key", await _userManager.GetAuthenticatorKeyAsync(user));
|
|
||||||
|
|
||||||
Response.Headers.TryAdd("Content-Disposition", "attachment; filename=PersonalData.json");
|
|
||||||
return new FileContentResult(JsonSerializer.SerializeToUtf8Bytes(personalData), "application/json");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
@page
|
|
||||||
@model EmailModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Manage Email";
|
|
||||||
ViewData["ActivePage"] = ManageNavPages.Email;
|
|
||||||
}
|
|
||||||
|
|
||||||
<h3>@ViewData["Title"]</h3>
|
|
||||||
<partial name="_StatusMessage" for="StatusMessage" />
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<form id="email-form" method="post">
|
|
||||||
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
|
|
||||||
@if (Model.IsEmailConfirmed)
|
|
||||||
{
|
|
||||||
<div class="form-floating mb-3 input-group">
|
|
||||||
<input asp-for="Email" class="form-control" placeholder="Please enter your email." disabled />
|
|
||||||
<div class="input-group-append">
|
|
||||||
<span class="h-100 input-group-text text-success font-weight-bold">✓</span>
|
|
||||||
</div>
|
|
||||||
<label asp-for="Email" class="form-label"></label>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Email" class="form-control" placeholder="Please enter your email." disabled />
|
|
||||||
<label asp-for="Email" class="form-label"></label>
|
|
||||||
<button id="email-verification" type="submit" asp-page-handler="SendVerificationEmail" class="btn btn-link">Send verification email</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.NewEmail" class="form-control" autocomplete="email" aria-required="true" placeholder="Please enter new email." />
|
|
||||||
<label asp-for="Input.NewEmail" class="form-label"></label>
|
|
||||||
<span asp-validation-for="Input.NewEmail" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<button id="change-email-button" type="submit" asp-page-handler="ChangeEmail" class="w-100 btn btn-lg btn-primary">Change email</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
@ -1,172 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Encodings.Web;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Identity.UI.Services;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
|
||||||
{
|
|
||||||
public class EmailModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly SignInManager<UserModel> _signInManager;
|
|
||||||
private readonly IEmailSender _emailSender;
|
|
||||||
|
|
||||||
public EmailModel(
|
|
||||||
UserManager<UserModel> userManager,
|
|
||||||
SignInManager<UserModel> signInManager,
|
|
||||||
IEmailSender emailSender)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_signInManager = signInManager;
|
|
||||||
_emailSender = emailSender;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public string Email { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsEmailConfirmed { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string StatusMessage { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[BindProperty]
|
|
||||||
public InputModel Input { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public class InputModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[EmailAddress]
|
|
||||||
[Display(Name = "New email")]
|
|
||||||
public string NewEmail { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadAsync(UserModel user)
|
|
||||||
{
|
|
||||||
var email = await _userManager.GetEmailAsync(user);
|
|
||||||
Email = email;
|
|
||||||
|
|
||||||
Input = new InputModel
|
|
||||||
{
|
|
||||||
NewEmail = email,
|
|
||||||
};
|
|
||||||
|
|
||||||
IsEmailConfirmed = await _userManager.IsEmailConfirmedAsync(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await LoadAsync(user);
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostChangeEmailAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
{
|
|
||||||
await LoadAsync(user);
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
var email = await _userManager.GetEmailAsync(user);
|
|
||||||
if (Input.NewEmail != email)
|
|
||||||
{
|
|
||||||
var userId = await _userManager.GetUserIdAsync(user);
|
|
||||||
var code = await _userManager.GenerateChangeEmailTokenAsync(user, Input.NewEmail);
|
|
||||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
|
||||||
var callbackUrl = Url.Page(
|
|
||||||
"/Account/ConfirmEmailChange",
|
|
||||||
pageHandler: null,
|
|
||||||
values: new { area = "Identity", userId = userId, email = Input.NewEmail, code = code },
|
|
||||||
protocol: Request.Scheme);
|
|
||||||
await _emailSender.SendEmailAsync(
|
|
||||||
Input.NewEmail,
|
|
||||||
"Confirm your email",
|
|
||||||
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
|
|
||||||
|
|
||||||
StatusMessage = "Confirmation link to change email sent. Please check your email.";
|
|
||||||
return RedirectToPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
StatusMessage = "Your email is unchanged.";
|
|
||||||
return RedirectToPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostSendVerificationEmailAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
{
|
|
||||||
await LoadAsync(user);
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
var userId = await _userManager.GetUserIdAsync(user);
|
|
||||||
var email = await _userManager.GetEmailAsync(user);
|
|
||||||
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
|
||||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
|
||||||
var callbackUrl = Url.Page(
|
|
||||||
"/Account/ConfirmEmail",
|
|
||||||
pageHandler: null,
|
|
||||||
values: new { area = "Identity", userId = userId, code = code },
|
|
||||||
protocol: Request.Scheme);
|
|
||||||
await _emailSender.SendEmailAsync(
|
|
||||||
email,
|
|
||||||
"Confirm your email",
|
|
||||||
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
|
|
||||||
|
|
||||||
StatusMessage = "Verification email sent. Please check your email.";
|
|
||||||
return RedirectToPage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
@page
|
|
||||||
@model EnableAuthenticatorModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Configure authenticator app";
|
|
||||||
ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
|
|
||||||
}
|
|
||||||
|
|
||||||
<partial name="_StatusMessage" for="StatusMessage" />
|
|
||||||
<h3>@ViewData["Title"]</h3>
|
|
||||||
<div>
|
|
||||||
<p>To use an authenticator app go through the following steps:</p>
|
|
||||||
<ol class="list">
|
|
||||||
<li>
|
|
||||||
<p>
|
|
||||||
Download a two-factor authenticator app like Microsoft Authenticator for
|
|
||||||
<a href="https://go.microsoft.com/fwlink/?Linkid=825072">Android</a> and
|
|
||||||
<a href="https://go.microsoft.com/fwlink/?Linkid=825073">iOS</a> or
|
|
||||||
Google Authenticator for
|
|
||||||
<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&hl=en">Android</a> and
|
|
||||||
<a href="https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8">iOS</a>.
|
|
||||||
</p>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<p>Scan the QR Code or enter this key <kbd>@Model.SharedKey</kbd> into your two factor authenticator app. Spaces and casing do not matter.</p>
|
|
||||||
<div class="alert alert-info">Learn how to <a href="https://go.microsoft.com/fwlink/?Linkid=852423">enable QR code generation</a>.</div>
|
|
||||||
<div id="qrCode"></div>
|
|
||||||
<div id="qrCodeData" data-url="@Model.AuthenticatorUri"></div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<p>
|
|
||||||
Once you have scanned the QR code or input the key above, your two factor authentication app will provide you
|
|
||||||
with a unique code. Enter the code in the confirmation box below.
|
|
||||||
</p>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<form id="send-code" method="post">
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.Code" class="form-control" autocomplete="off" placeholder="Please enter the code."/>
|
|
||||||
<label asp-for="Input.Code" class="control-label form-label">Verification Code</label>
|
|
||||||
<span asp-validation-for="Input.Code" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Verify</button>
|
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
@ -1,189 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Globalization;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Encodings.Web;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
|
||||||
{
|
|
||||||
public class EnableAuthenticatorModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly ILogger<EnableAuthenticatorModel> _logger;
|
|
||||||
private readonly UrlEncoder _urlEncoder;
|
|
||||||
|
|
||||||
private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
|
|
||||||
|
|
||||||
public EnableAuthenticatorModel(
|
|
||||||
UserManager<UserModel> userManager,
|
|
||||||
ILogger<EnableAuthenticatorModel> logger,
|
|
||||||
UrlEncoder urlEncoder)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_logger = logger;
|
|
||||||
_urlEncoder = urlEncoder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public string SharedKey { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public string AuthenticatorUri { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string[] RecoveryCodes { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string StatusMessage { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[BindProperty]
|
|
||||||
public InputModel Input { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public class InputModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
|
||||||
[DataType(DataType.Text)]
|
|
||||||
[Display(Name = "Verification Code")]
|
|
||||||
public string Code { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await LoadSharedKeyAndQrCodeUriAsync(user);
|
|
||||||
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
{
|
|
||||||
await LoadSharedKeyAndQrCodeUriAsync(user);
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip spaces and hyphens
|
|
||||||
var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty);
|
|
||||||
|
|
||||||
var is2faTokenValid = await _userManager.VerifyTwoFactorTokenAsync(
|
|
||||||
user, _userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
|
|
||||||
|
|
||||||
if (!is2faTokenValid)
|
|
||||||
{
|
|
||||||
ModelState.AddModelError("Input.Code", "Verification code is invalid.");
|
|
||||||
await LoadSharedKeyAndQrCodeUriAsync(user);
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _userManager.SetTwoFactorEnabledAsync(user, true);
|
|
||||||
var userId = await _userManager.GetUserIdAsync(user);
|
|
||||||
_logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", userId);
|
|
||||||
|
|
||||||
StatusMessage = "Your authenticator app has been verified.";
|
|
||||||
|
|
||||||
if (await _userManager.CountRecoveryCodesAsync(user) == 0)
|
|
||||||
{
|
|
||||||
var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
|
|
||||||
RecoveryCodes = recoveryCodes.ToArray();
|
|
||||||
return RedirectToPage("./ShowRecoveryCodes");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
return RedirectToPage("./TwoFactorAuthentication");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadSharedKeyAndQrCodeUriAsync(UserModel user)
|
|
||||||
{
|
|
||||||
// Load the authenticator key & QR code URI to display on the form
|
|
||||||
var unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
|
|
||||||
if (string.IsNullOrEmpty(unformattedKey))
|
|
||||||
{
|
|
||||||
await _userManager.ResetAuthenticatorKeyAsync(user);
|
|
||||||
unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
SharedKey = FormatKey(unformattedKey);
|
|
||||||
|
|
||||||
var email = await _userManager.GetEmailAsync(user);
|
|
||||||
AuthenticatorUri = GenerateQrCodeUri(email, unformattedKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
private string FormatKey(string unformattedKey)
|
|
||||||
{
|
|
||||||
var result = new StringBuilder();
|
|
||||||
int currentPosition = 0;
|
|
||||||
while (currentPosition + 4 < unformattedKey.Length)
|
|
||||||
{
|
|
||||||
result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' ');
|
|
||||||
currentPosition += 4;
|
|
||||||
}
|
|
||||||
if (currentPosition < unformattedKey.Length)
|
|
||||||
{
|
|
||||||
result.Append(unformattedKey.AsSpan(currentPosition));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.ToString().ToLowerInvariant();
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GenerateQrCodeUri(string email, string unformattedKey)
|
|
||||||
{
|
|
||||||
return string.Format(
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
AuthenticatorUriFormat,
|
|
||||||
_urlEncoder.Encode("Microsoft.AspNetCore.Identity.UI"),
|
|
||||||
_urlEncoder.Encode(email),
|
|
||||||
unformattedKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
@page
|
|
||||||
@model ExternalLoginsModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Manage your external logins";
|
|
||||||
ViewData["ActivePage"] = ManageNavPages.ExternalLogins;
|
|
||||||
}
|
|
||||||
|
|
||||||
<partial name="_StatusMessage" for="StatusMessage" />
|
|
||||||
@if (Model.CurrentLogins?.Count > 0)
|
|
||||||
{
|
|
||||||
<h3>Registered Logins</h3>
|
|
||||||
<table class="table">
|
|
||||||
<tbody>
|
|
||||||
@foreach (var login in Model.CurrentLogins)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td id="@($"login-provider-{login.LoginProvider}")">@login.ProviderDisplayName</td>
|
|
||||||
<td>
|
|
||||||
@if (Model.ShowRemoveButton)
|
|
||||||
{
|
|
||||||
<form id="@($"remove-login-{login.LoginProvider}")" asp-page-handler="RemoveLogin" method="post">
|
|
||||||
<div>
|
|
||||||
<input asp-for="@login.LoginProvider" name="LoginProvider" type="hidden" />
|
|
||||||
<input asp-for="@login.ProviderKey" name="ProviderKey" type="hidden" />
|
|
||||||
<button type="submit" class="btn btn-primary" title="Remove this @login.ProviderDisplayName login from your account">Remove</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
@:
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
@if (Model.OtherLogins?.Count > 0)
|
|
||||||
{
|
|
||||||
<h4>Add another service to log in.</h4>
|
|
||||||
<hr />
|
|
||||||
<form id="link-login-form" asp-page-handler="LinkLogin" method="post" class="form-horizontal">
|
|
||||||
<div id="socialLoginList">
|
|
||||||
<p>
|
|
||||||
@foreach (var provider in Model.OtherLogins)
|
|
||||||
{
|
|
||||||
<button id="@($"link-login-button-{provider.Name}")" type="submit" class="btn btn-primary" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
|
||||||
{
|
|
||||||
public class ExternalLoginsModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly SignInManager<UserModel> _signInManager;
|
|
||||||
private readonly IUserStore<UserModel> _userStore;
|
|
||||||
|
|
||||||
public ExternalLoginsModel(
|
|
||||||
UserManager<UserModel> userManager,
|
|
||||||
SignInManager<UserModel> signInManager,
|
|
||||||
IUserStore<UserModel> userStore)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_signInManager = signInManager;
|
|
||||||
_userStore = userStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public IList<UserLoginInfo> CurrentLogins { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public IList<AuthenticationScheme> OtherLogins { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public bool ShowRemoveButton { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string StatusMessage { get; set; }
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
CurrentLogins = await _userManager.GetLoginsAsync(user);
|
|
||||||
OtherLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync())
|
|
||||||
.Where(auth => CurrentLogins.All(ul => auth.Name != ul.LoginProvider))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
string passwordHash = null;
|
|
||||||
if (_userStore is IUserPasswordStore<UserModel> userPasswordStore)
|
|
||||||
{
|
|
||||||
passwordHash = await userPasswordStore.GetPasswordHashAsync(user, HttpContext.RequestAborted);
|
|
||||||
}
|
|
||||||
|
|
||||||
ShowRemoveButton = passwordHash != null || CurrentLogins.Count > 1;
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostRemoveLoginAsync(string loginProvider, string providerKey)
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _userManager.RemoveLoginAsync(user, loginProvider, providerKey);
|
|
||||||
if (!result.Succeeded)
|
|
||||||
{
|
|
||||||
StatusMessage = "The external login was not removed.";
|
|
||||||
return RedirectToPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _signInManager.RefreshSignInAsync(user);
|
|
||||||
StatusMessage = "The external login was removed.";
|
|
||||||
return RedirectToPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostLinkLoginAsync(string provider)
|
|
||||||
{
|
|
||||||
// Clear the existing external cookie to ensure a clean login process
|
|
||||||
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
|
|
||||||
|
|
||||||
// Request a redirect to the external login provider to link a login for the current user
|
|
||||||
var redirectUrl = Url.Page("./ExternalLogins", pageHandler: "LinkLoginCallback");
|
|
||||||
var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, _userManager.GetUserId(User));
|
|
||||||
return new ChallengeResult(provider, properties);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetLinkLoginCallbackAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var userId = await _userManager.GetUserIdAsync(user);
|
|
||||||
var info = await _signInManager.GetExternalLoginInfoAsync(userId);
|
|
||||||
if (info == null)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Unexpected error occurred loading external login info.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _userManager.AddLoginAsync(user, info);
|
|
||||||
if (!result.Succeeded)
|
|
||||||
{
|
|
||||||
StatusMessage = "The external login was not added. External logins can only be associated with one account.";
|
|
||||||
return RedirectToPage();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the existing external cookie to ensure a clean login process
|
|
||||||
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
|
|
||||||
|
|
||||||
StatusMessage = "The external login was added.";
|
|
||||||
return RedirectToPage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
@page
|
|
||||||
@model GenerateRecoveryCodesModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Generate two-factor authentication (2FA) recovery codes";
|
|
||||||
ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
|
|
||||||
}
|
|
||||||
|
|
||||||
<partial name="_StatusMessage" for="StatusMessage" />
|
|
||||||
<h3>@ViewData["Title"]</h3>
|
|
||||||
<div class="alert alert-warning" role="alert">
|
|
||||||
<p>
|
|
||||||
<span class="glyphicon glyphicon-warning-sign"></span>
|
|
||||||
<strong>Put these codes in a safe place.</strong>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If you lose your device and don't have the recovery codes you will lose access to your account.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Generating new recovery codes does not change the keys used in authenticator apps. If you wish to change the key
|
|
||||||
used in an authenticator app you should <a asp-page="./ResetAuthenticator">reset your authenticator keys.</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<form method="post">
|
|
||||||
<button class="btn btn-danger" type="submit">Generate Recovery Codes</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
|
||||||
{
|
|
||||||
public class GenerateRecoveryCodesModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly ILogger<GenerateRecoveryCodesModel> _logger;
|
|
||||||
|
|
||||||
public GenerateRecoveryCodesModel(
|
|
||||||
UserManager<UserModel> userManager,
|
|
||||||
ILogger<GenerateRecoveryCodesModel> logger)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string[] RecoveryCodes { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string StatusMessage { get; set; }
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user);
|
|
||||||
if (!isTwoFactorEnabled)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Cannot generate recovery codes for user because they do not have 2FA enabled.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user);
|
|
||||||
var userId = await _userManager.GetUserIdAsync(user);
|
|
||||||
if (!isTwoFactorEnabled)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Cannot generate recovery codes for user as they do not have 2FA enabled.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
|
|
||||||
RecoveryCodes = recoveryCodes.ToArray();
|
|
||||||
|
|
||||||
_logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId);
|
|
||||||
StatusMessage = "You have generated new recovery codes.";
|
|
||||||
return RedirectToPage("./ShowRecoveryCodes");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
@page
|
|
||||||
@model IndexModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Profile";
|
|
||||||
ViewData["ActivePage"] = ManageNavPages.Index;
|
|
||||||
}
|
|
||||||
|
|
||||||
<h3>@ViewData["Title"]</h3>
|
|
||||||
<partial name="_StatusMessage" for="StatusMessage" />
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<form id="profile-form" method="post">
|
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Username" class="form-control" placeholder="Please choose your username." disabled />
|
|
||||||
<label asp-for="Username" class="form-label"></label>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.PhoneNumber" class="form-control" placeholder="Please enter your phone number."/>
|
|
||||||
<label asp-for="Input.PhoneNumber" class="form-label"></label>
|
|
||||||
<span asp-validation-for="Input.PhoneNumber" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<button id="update-profile-button" type="submit" class="w-100 btn btn-lg btn-primary">Save</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
@ -1,119 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Text.Encodings.Web;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
|
||||||
{
|
|
||||||
public class IndexModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly SignInManager<UserModel> _signInManager;
|
|
||||||
|
|
||||||
public IndexModel(
|
|
||||||
UserManager<UserModel> userManager,
|
|
||||||
SignInManager<UserModel> signInManager)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_signInManager = signInManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public string Username { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string StatusMessage { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[BindProperty]
|
|
||||||
public InputModel Input { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public class InputModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Phone]
|
|
||||||
[Display(Name = "Phone number")]
|
|
||||||
public string PhoneNumber { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadAsync(UserModel user)
|
|
||||||
{
|
|
||||||
var userName = await _userManager.GetUserNameAsync(user);
|
|
||||||
var phoneNumber = await _userManager.GetPhoneNumberAsync(user);
|
|
||||||
|
|
||||||
Username = userName;
|
|
||||||
|
|
||||||
Input = new InputModel
|
|
||||||
{
|
|
||||||
PhoneNumber = phoneNumber
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await LoadAsync(user);
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
{
|
|
||||||
await LoadAsync(user);
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
var phoneNumber = await _userManager.GetPhoneNumberAsync(user);
|
|
||||||
if (Input.PhoneNumber != phoneNumber)
|
|
||||||
{
|
|
||||||
var setPhoneResult = await _userManager.SetPhoneNumberAsync(user, Input.PhoneNumber);
|
|
||||||
if (!setPhoneResult.Succeeded)
|
|
||||||
{
|
|
||||||
StatusMessage = "Unexpected error when trying to set phone number.";
|
|
||||||
return RedirectToPage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _signInManager.RefreshSignInAsync(user);
|
|
||||||
StatusMessage = "Your profile has been updated";
|
|
||||||
return RedirectToPage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static class ManageNavPages
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string Index => "Index";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string Email => "Email";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string ChangePassword => "ChangePassword";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string DownloadPersonalData => "DownloadPersonalData";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string DeletePersonalData => "DeletePersonalData";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string ExternalLogins => "ExternalLogins";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string PersonalData => "PersonalData";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string TwoFactorAuthentication => "TwoFactorAuthentication";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string EmailNavClass(ViewContext viewContext) => PageNavClass(viewContext, Email);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string ChangePasswordNavClass(ViewContext viewContext) => PageNavClass(viewContext, ChangePassword);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string DownloadPersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, DownloadPersonalData);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string DeletePersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, DeletePersonalData);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string ExternalLoginsNavClass(ViewContext viewContext) => PageNavClass(viewContext, ExternalLogins);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string PersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, PersonalData);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string TwoFactorAuthenticationNavClass(ViewContext viewContext) => PageNavClass(viewContext, TwoFactorAuthentication);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public static string PageNavClass(ViewContext viewContext, string page)
|
|
||||||
{
|
|
||||||
var activePage = viewContext.ViewData["ActivePage"] as string
|
|
||||||
?? System.IO.Path.GetFileNameWithoutExtension(viewContext.ActionDescriptor.DisplayName);
|
|
||||||
return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? "active" : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
@page
|
|
||||||
@model PersonalDataModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Personal Data";
|
|
||||||
ViewData["ActivePage"] = ManageNavPages.PersonalData;
|
|
||||||
}
|
|
||||||
|
|
||||||
<h3>@ViewData["Title"]</h3>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<p>Your account contains personal data that you have given us. This page allows you to download or delete that data.</p>
|
|
||||||
<p>
|
|
||||||
<strong>Deleting this data will permanently remove your account, and this cannot be recovered.</strong>
|
|
||||||
</p>
|
|
||||||
<form id="download-data" asp-page="DownloadPersonalData" method="post">
|
|
||||||
<button class="btn btn-primary" type="submit">Download</button>
|
|
||||||
</form>
|
|
||||||
<p>
|
|
||||||
<a id="delete" asp-page="DeletePersonalData" class="btn btn-danger">Delete</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
|
||||||
{
|
|
||||||
public class PersonalDataModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly ILogger<PersonalDataModel> _logger;
|
|
||||||
|
|
||||||
public PersonalDataModel(
|
|
||||||
UserManager<UserModel> userManager,
|
|
||||||
ILogger<PersonalDataModel> logger)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGet()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
@page
|
|
||||||
@model ResetAuthenticatorModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Reset authenticator key";
|
|
||||||
ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
|
|
||||||
}
|
|
||||||
|
|
||||||
<partial name="_StatusMessage" for="StatusMessage" />
|
|
||||||
<h3>@ViewData["Title"]</h3>
|
|
||||||
<div class="alert alert-warning" role="alert">
|
|
||||||
<p>
|
|
||||||
<span class="glyphicon glyphicon-warning-sign"></span>
|
|
||||||
<strong>If you reset your authenticator key your authenticator app will not work until you reconfigure it.</strong>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
This process disables 2FA until you verify your authenticator app.
|
|
||||||
If you do not complete your authenticator app configuration you may lose access to your account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<form id="reset-authenticator-form" method="post">
|
|
||||||
<button id="reset-authenticator-button" class="btn btn-danger" type="submit">Reset authenticator key</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
|
||||||
{
|
|
||||||
public class ResetAuthenticatorModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly SignInManager<UserModel> _signInManager;
|
|
||||||
private readonly ILogger<ResetAuthenticatorModel> _logger;
|
|
||||||
|
|
||||||
public ResetAuthenticatorModel(
|
|
||||||
UserManager<UserModel> userManager,
|
|
||||||
SignInManager<UserModel> signInManager,
|
|
||||||
ILogger<ResetAuthenticatorModel> logger)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_signInManager = signInManager;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string StatusMessage { get; set; }
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGet()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await _userManager.SetTwoFactorEnabledAsync(user, false);
|
|
||||||
await _userManager.ResetAuthenticatorKeyAsync(user);
|
|
||||||
var userId = await _userManager.GetUserIdAsync(user);
|
|
||||||
_logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", user.Id);
|
|
||||||
|
|
||||||
await _signInManager.RefreshSignInAsync(user);
|
|
||||||
StatusMessage = "Your authenticator app key has been reset, you will need to configure your authenticator app using the new key.";
|
|
||||||
|
|
||||||
return RedirectToPage("./EnableAuthenticator");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
@page
|
|
||||||
@model SetPasswordModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Set password";
|
|
||||||
ViewData["ActivePage"] = ManageNavPages.ChangePassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
<h3>Set your password</h3>
|
|
||||||
<partial name="_StatusMessage" for="StatusMessage" />
|
|
||||||
<p class="text-info">
|
|
||||||
You do not have a local username/password for this site. Add a local
|
|
||||||
account so you can log in without an external login.
|
|
||||||
</p>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<form id="set-password-form" method="post">
|
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.NewPassword" class="form-control" autocomplete="new-password" placeholder="Please enter your new password."/>
|
|
||||||
<label asp-for="Input.NewPassword" class="form-label"></label>
|
|
||||||
<span asp-validation-for="Input.NewPassword" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" placeholder="Please confirm your new password."/>
|
|
||||||
<label asp-for="Input.ConfirmPassword" class="form-label"></label>
|
|
||||||
<span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Set password</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
|
||||||
{
|
|
||||||
public class SetPasswordModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly SignInManager<UserModel> _signInManager;
|
|
||||||
|
|
||||||
public SetPasswordModel(
|
|
||||||
UserManager<UserModel> userManager,
|
|
||||||
SignInManager<UserModel> signInManager)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_signInManager = signInManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[BindProperty]
|
|
||||||
public InputModel Input { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string StatusMessage { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public class InputModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
|
||||||
[DataType(DataType.Password)]
|
|
||||||
[Display(Name = "New password")]
|
|
||||||
public string NewPassword { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[DataType(DataType.Password)]
|
|
||||||
[Display(Name = "Confirm new password")]
|
|
||||||
[Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
|
|
||||||
public string ConfirmPassword { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasPassword = await _userManager.HasPasswordAsync(user);
|
|
||||||
|
|
||||||
if (hasPassword)
|
|
||||||
{
|
|
||||||
return RedirectToPage("./ChangePassword");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
|
||||||
{
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
{
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var addPasswordResult = await _userManager.AddPasswordAsync(user, Input.NewPassword);
|
|
||||||
if (!addPasswordResult.Succeeded)
|
|
||||||
{
|
|
||||||
foreach (var error in addPasswordResult.Errors)
|
|
||||||
{
|
|
||||||
ModelState.AddModelError(string.Empty, error.Description);
|
|
||||||
}
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
await _signInManager.RefreshSignInAsync(user);
|
|
||||||
StatusMessage = "Your password has been set.";
|
|
||||||
|
|
||||||
return RedirectToPage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
@page
|
|
||||||
@model ShowRecoveryCodesModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Recovery codes";
|
|
||||||
ViewData["ActivePage"] = "TwoFactorAuthentication";
|
|
||||||
}
|
|
||||||
|
|
||||||
<partial name="_StatusMessage" for="StatusMessage" />
|
|
||||||
<h3>@ViewData["Title"]</h3>
|
|
||||||
<div class="alert alert-warning" role="alert">
|
|
||||||
<p>
|
|
||||||
<strong>Put these codes in a safe place.</strong>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If you lose your device and don't have the recovery codes you will lose access to your account.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-12">
|
|
||||||
@for (var row = 0; row < Model.RecoveryCodes.Length; row += 2)
|
|
||||||
{
|
|
||||||
<code class="recovery-code">@Model.RecoveryCodes[row]</code><text> </text><code class="recovery-code">@Model.RecoveryCodes[row + 1]</code><br />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public class ShowRecoveryCodesModel : PageModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string[] RecoveryCodes { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string StatusMessage { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public IActionResult OnGet()
|
|
||||||
{
|
|
||||||
if (RecoveryCodes == null || RecoveryCodes.Length == 0)
|
|
||||||
{
|
|
||||||
return RedirectToPage("./TwoFactorAuthentication");
|
|
||||||
}
|
|
||||||
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
@page
|
|
||||||
@using Microsoft.AspNetCore.Http.Features
|
|
||||||
@model TwoFactorAuthenticationModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Two-factor authentication (2FA)";
|
|
||||||
ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
|
|
||||||
}
|
|
||||||
|
|
||||||
<partial name="_StatusMessage" for="StatusMessage" />
|
|
||||||
<h3>@ViewData["Title"]</h3>
|
|
||||||
@{
|
|
||||||
var consentFeature = HttpContext.Features.Get<ITrackingConsentFeature>();
|
|
||||||
@if (consentFeature?.CanTrack ?? true)
|
|
||||||
{
|
|
||||||
@if (Model.Is2faEnabled)
|
|
||||||
{
|
|
||||||
if (Model.RecoveryCodesLeft == 0)
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
<strong>You have no recovery codes left.</strong>
|
|
||||||
<p>You must <a asp-page="./GenerateRecoveryCodes">generate a new set of recovery codes</a> before you can log in with a recovery code.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (Model.RecoveryCodesLeft == 1)
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
<strong>You have 1 recovery code left.</strong>
|
|
||||||
<p>You can <a asp-page="./GenerateRecoveryCodes">generate a new set of recovery codes</a>.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else if (Model.RecoveryCodesLeft <= 3)
|
|
||||||
{
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
<strong>You have @Model.RecoveryCodesLeft recovery codes left.</strong>
|
|
||||||
<p>You should <a asp-page="./GenerateRecoveryCodes">generate a new set of recovery codes</a>.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Model.IsMachineRemembered)
|
|
||||||
{
|
|
||||||
<form method="post" style="display: inline-block">
|
|
||||||
<button type="submit" class="btn btn-primary">Forget this browser</button>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
<a asp-page="./Disable2fa" class="btn btn-primary">Disable 2FA</a>
|
|
||||||
<a asp-page="./GenerateRecoveryCodes" class="btn btn-primary">Reset recovery codes</a>
|
|
||||||
}
|
|
||||||
|
|
||||||
<h4>Authenticator app</h4>
|
|
||||||
@if (!Model.HasAuthenticator)
|
|
||||||
{
|
|
||||||
<a id="enable-authenticator" asp-page="./EnableAuthenticator" class="btn btn-primary">Add authenticator app</a>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<a id="enable-authenticator" asp-page="./EnableAuthenticator" class="btn btn-primary">Set up authenticator app</a>
|
|
||||||
<a id="reset-authenticator" asp-page="./ResetAuthenticator" class="btn btn-primary">Reset authenticator app</a>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
<strong>Privacy and cookie policy have not been accepted.</strong>
|
|
||||||
<p>You must accept the policy before you can enable two factor authentication.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
|
||||||
{
|
|
||||||
public class TwoFactorAuthenticationModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly SignInManager<UserModel> _signInManager;
|
|
||||||
private readonly ILogger<TwoFactorAuthenticationModel> _logger;
|
|
||||||
|
|
||||||
public TwoFactorAuthenticationModel(
|
|
||||||
UserManager<UserModel> userManager, SignInManager<UserModel> signInManager, ILogger<TwoFactorAuthenticationModel> logger)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_signInManager = signInManager;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public bool HasAuthenticator { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public int RecoveryCodesLeft { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[BindProperty]
|
|
||||||
public bool Is2faEnabled { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsMachineRemembered { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[TempData]
|
|
||||||
public string StatusMessage { get; set; }
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
HasAuthenticator = await _userManager.GetAuthenticatorKeyAsync(user) != null;
|
|
||||||
Is2faEnabled = await _userManager.GetTwoFactorEnabledAsync(user);
|
|
||||||
IsMachineRemembered = await _signInManager.IsTwoFactorClientRememberedAsync(user);
|
|
||||||
RecoveryCodesLeft = await _userManager.CountRecoveryCodesAsync(user);
|
|
||||||
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
|
||||||
{
|
|
||||||
var user = await _userManager.GetUserAsync(User);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await _signInManager.ForgetTwoFactorClientAsync();
|
|
||||||
StatusMessage = "The current browser has been forgotten. When you login again from this browser you will be prompted for your 2fa code.";
|
|
||||||
return RedirectToPage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
@{
|
|
||||||
if (ViewData.TryGetValue("ParentLayout", out var parentLayout) && parentLayout != null)
|
|
||||||
{
|
|
||||||
Layout = parentLayout.ToString();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Layout = "/Areas/Identity/Pages/_Layout.cshtml";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
<h1>Manage your account</h1>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2>Change your account settings</h2>
|
|
||||||
<hr />
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<partial name="_ManageNav" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-9">
|
|
||||||
@RenderBody()
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
@RenderSection("Scripts", required: false)
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
@inject SignInManager<UserModel> SignInManager
|
|
||||||
@{
|
|
||||||
var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any();
|
|
||||||
}
|
|
||||||
<ul class="nav nav-pills flex-column">
|
|
||||||
<li class="nav-item"><a class="nav-link @ManageNavPages.IndexNavClass(ViewContext)" id="profile" asp-page="./Index">Profile</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link @ManageNavPages.EmailNavClass(ViewContext)" id="email" asp-page="./Email">Email</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link @ManageNavPages.ChangePasswordNavClass(ViewContext)" id="change-password" asp-page="./ChangePassword">Password</a></li>
|
|
||||||
@if (hasExternalLogins)
|
|
||||||
{
|
|
||||||
<li id="external-logins" class="nav-item"><a id="external-login" class="nav-link @ManageNavPages.ExternalLoginsNavClass(ViewContext)" asp-page="./ExternalLogins">External logins</a></li>
|
|
||||||
}
|
|
||||||
<li class="nav-item"><a class="nav-link @ManageNavPages.TwoFactorAuthenticationNavClass(ViewContext)" id="two-factor" asp-page="./TwoFactorAuthentication">Two-factor authentication</a></li>
|
|
||||||
<li class="nav-item"><a class="nav-link @ManageNavPages.PersonalDataNavClass(ViewContext)" id="personal-data" asp-page="./PersonalData">Personal data</a></li>
|
|
||||||
</ul>
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
@model string
|
|
||||||
|
|
||||||
@if (!String.IsNullOrEmpty(Model))
|
|
||||||
{
|
|
||||||
var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success";
|
|
||||||
<div class="alert alert-@statusMessageClass alert-dismissible" role="alert">
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
||||||
@Model
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
@using PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
@page
|
|
||||||
@model RegisterModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Register";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h1>@ViewData["Title"]</h1>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<form id="registerForm" asp-route-returnUrl="@Model.ReturnUrl" method="post">
|
|
||||||
<h2>Create a new account.</h2>
|
|
||||||
<hr />
|
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
|
|
||||||
<label asp-for="Input.Email">Email</label>
|
|
||||||
<span asp-validation-for="Input.Email" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
|
|
||||||
<label asp-for="Input.Password">Password</label>
|
|
||||||
<span asp-validation-for="Input.Password" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
|
|
||||||
<label asp-for="Input.ConfirmPassword">Confirm Password</label>
|
|
||||||
<span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<button id="registerSubmit" type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6 col-md-offset-2">
|
|
||||||
<section>
|
|
||||||
<h3>Use another service to register.</h3>
|
|
||||||
<hr />
|
|
||||||
@{
|
|
||||||
if ((Model.ExternalLogins?.Count ?? 0) == 0)
|
|
||||||
{
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
There are no external authentication services configured. See this <a href="https://go.microsoft.com/fwlink/?LinkID=532715">article
|
|
||||||
about setting up this ASP.NET application to support logging in via external services</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<form id="external-account" asp-page="./ExternalLogin" asp-route-returnUrl="@Model.ReturnUrl" method="post" class="form-horizontal">
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
@foreach (var provider in Model.ExternalLogins!)
|
|
||||||
{
|
|
||||||
<button type="submit" class="btn btn-primary" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
@ -1,182 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Encodings.Web;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Identity.UI.Services;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
{
|
|
||||||
[Authorize]
|
|
||||||
public class RegisterModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly SignInManager<UserModel> _signInManager;
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly IUserStore<UserModel> _userStore;
|
|
||||||
private readonly IUserEmailStore<UserModel> _emailStore;
|
|
||||||
private readonly ILogger<RegisterModel> _logger;
|
|
||||||
private readonly IEmailSender _emailSender;
|
|
||||||
|
|
||||||
public RegisterModel(
|
|
||||||
UserManager<UserModel> userManager,
|
|
||||||
IUserStore<UserModel> userStore,
|
|
||||||
SignInManager<UserModel> signInManager,
|
|
||||||
ILogger<RegisterModel> logger,
|
|
||||||
IEmailSender emailSender)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_userStore = userStore;
|
|
||||||
_emailStore = GetEmailStore();
|
|
||||||
_signInManager = signInManager;
|
|
||||||
_logger = logger;
|
|
||||||
_emailSender = emailSender;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[BindProperty]
|
|
||||||
public InputModel Input { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public string ReturnUrl { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public IList<AuthenticationScheme> ExternalLogins { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public class InputModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[EmailAddress]
|
|
||||||
[Display(Name = "Email")]
|
|
||||||
public string Email { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
|
||||||
[DataType(DataType.Password)]
|
|
||||||
[Display(Name = "Password")]
|
|
||||||
public string Password { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[DataType(DataType.Password)]
|
|
||||||
[Display(Name = "Confirm password")]
|
|
||||||
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
|
|
||||||
public string ConfirmPassword { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public async Task OnGetAsync(string returnUrl = null)
|
|
||||||
{
|
|
||||||
ReturnUrl = returnUrl;
|
|
||||||
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
|
|
||||||
{
|
|
||||||
returnUrl ??= Url.Content("~/");
|
|
||||||
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
|
|
||||||
if (ModelState.IsValid)
|
|
||||||
{
|
|
||||||
var user = CreateUser();
|
|
||||||
|
|
||||||
await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
|
|
||||||
await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
|
|
||||||
var result = await _userManager.CreateAsync(user, Input.Password);
|
|
||||||
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("User created a new account with password.");
|
|
||||||
|
|
||||||
var userId = await _userManager.GetUserIdAsync(user);
|
|
||||||
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
|
||||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
|
||||||
var callbackUrl = Url.Page(
|
|
||||||
"/Account/ConfirmEmail",
|
|
||||||
pageHandler: null,
|
|
||||||
values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl },
|
|
||||||
protocol: Request.Scheme);
|
|
||||||
|
|
||||||
await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
|
|
||||||
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
|
|
||||||
|
|
||||||
if (_userManager.Options.SignIn.RequireConfirmedAccount)
|
|
||||||
{
|
|
||||||
return RedirectToPage("RegisterConfirmation", new { email = Input.Email, returnUrl = returnUrl });
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await _signInManager.SignInAsync(user, isPersistent: false);
|
|
||||||
return LocalRedirect(returnUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach (var error in result.Errors)
|
|
||||||
{
|
|
||||||
ModelState.AddModelError(string.Empty, error.Description);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we got this far, something failed, redisplay form
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
private UserModel CreateUser()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return Activator.CreateInstance<UserModel>();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Can't create an instance of '{nameof(UserModel)}'. " +
|
|
||||||
$"Ensure that '{nameof(UserModel)}' is not an abstract class and has a parameterless constructor, or alternatively " +
|
|
||||||
$"override the register page in /Areas/Identity/Pages/Account/Register.cshtml");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private IUserEmailStore<UserModel> GetEmailStore()
|
|
||||||
{
|
|
||||||
if (!_userManager.SupportsUserEmail)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException("The default UI requires a user store with email support.");
|
|
||||||
}
|
|
||||||
return (IUserEmailStore<UserModel>)_userStore;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
@page
|
|
||||||
@model RegisterConfirmationModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Register confirmation";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h1>@ViewData["Title"]</h1>
|
|
||||||
@{
|
|
||||||
if (@Model.DisplayConfirmAccountLink)
|
|
||||||
{
|
|
||||||
<p>
|
|
||||||
This app does not currently have a real email sender registered, see <a href="https://aka.ms/aspaccountconf">these docs</a> for how to configure a real email sender.
|
|
||||||
Normally this would be emailed: <a id="confirm-link" href="@Model.EmailConfirmationUrl">Click here to confirm your account</a>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<p>
|
|
||||||
Please check your email to confirm your account.
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Identity.UI.Services;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
{
|
|
||||||
[AllowAnonymous]
|
|
||||||
public class RegisterConfirmationModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly IEmailSender _sender;
|
|
||||||
|
|
||||||
public RegisterConfirmationModel(UserManager<UserModel> userManager, IEmailSender sender)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_sender = sender;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public string Email { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public bool DisplayConfirmAccountLink { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public string EmailConfirmationUrl { get; set; }
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnGetAsync(string email, string returnUrl = null)
|
|
||||||
{
|
|
||||||
if (email == null)
|
|
||||||
{
|
|
||||||
return RedirectToPage("/Index");
|
|
||||||
}
|
|
||||||
returnUrl = returnUrl ?? Url.Content("~/");
|
|
||||||
|
|
||||||
var user = await _userManager.FindByEmailAsync(email);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
return NotFound($"Unable to load user with email '{email}'.");
|
|
||||||
}
|
|
||||||
|
|
||||||
Email = email;
|
|
||||||
// Once you add a real email sender, you should remove this code that lets you confirm the account
|
|
||||||
DisplayConfirmAccountLink = true;
|
|
||||||
if (DisplayConfirmAccountLink)
|
|
||||||
{
|
|
||||||
var userId = await _userManager.GetUserIdAsync(user);
|
|
||||||
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
|
||||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
|
||||||
EmailConfirmationUrl = Url.Page(
|
|
||||||
"/Account/ConfirmEmail",
|
|
||||||
pageHandler: null,
|
|
||||||
values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl },
|
|
||||||
protocol: Request.Scheme);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
@page
|
|
||||||
@model ResendEmailConfirmationModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Resend email confirmation";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h1>@ViewData["Title"]</h1>
|
|
||||||
<h2>Enter your email.</h2>
|
|
||||||
<hr />
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<form method="post">
|
|
||||||
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.Email" class="form-control" aria-required="true" placeholder="name@example.com" />
|
|
||||||
<label asp-for="Input.Email" class="form-label"></label>
|
|
||||||
<span asp-validation-for="Input.Email" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Resend</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Encodings.Web;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Identity.UI.Services;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
{
|
|
||||||
[AllowAnonymous]
|
|
||||||
public class ResendEmailConfirmationModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
private readonly IEmailSender _emailSender;
|
|
||||||
|
|
||||||
public ResendEmailConfirmationModel(UserManager<UserModel> userManager, IEmailSender emailSender)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
_emailSender = emailSender;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[BindProperty]
|
|
||||||
public InputModel Input { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public class InputModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[EmailAddress]
|
|
||||||
public string Email { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public void OnGet()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
|
||||||
{
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
{
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = await _userManager.FindByEmailAsync(Input.Email);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email.");
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
var userId = await _userManager.GetUserIdAsync(user);
|
|
||||||
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
|
|
||||||
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
|
|
||||||
var callbackUrl = Url.Page(
|
|
||||||
"/Account/ConfirmEmail",
|
|
||||||
pageHandler: null,
|
|
||||||
values: new { userId = userId, code = code },
|
|
||||||
protocol: Request.Scheme);
|
|
||||||
await _emailSender.SendEmailAsync(
|
|
||||||
Input.Email,
|
|
||||||
"Confirm your email",
|
|
||||||
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
|
|
||||||
|
|
||||||
ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email.");
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
@page
|
|
||||||
@model ResetPasswordModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Reset password";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h1>@ViewData["Title"]</h1>
|
|
||||||
<h2>Reset your password.</h2>
|
|
||||||
<hr />
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<form method="post">
|
|
||||||
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
|
|
||||||
<input asp-for="Input.Code" type="hidden" />
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
|
|
||||||
<label asp-for="Input.Email" class="form-label"></label>
|
|
||||||
<span asp-validation-for="Input.Email" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please enter your password." />
|
|
||||||
<label asp-for="Input.Password" class="form-label"></label>
|
|
||||||
<span asp-validation-for="Input.Password" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<div class="form-floating mb-3">
|
|
||||||
<input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your password." />
|
|
||||||
<label asp-for="Input.ConfirmPassword" class="form-label"></label>
|
|
||||||
<span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="w-100 btn btn-lg btn-primary">Reset</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@section Scripts {
|
|
||||||
<partial name="_ValidationScriptsPartial" />
|
|
||||||
}
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
|
||||||
using PSTW_CentralSystem.Models;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
{
|
|
||||||
public class ResetPasswordModel : PageModel
|
|
||||||
{
|
|
||||||
private readonly UserManager<UserModel> _userManager;
|
|
||||||
|
|
||||||
public ResetPasswordModel(UserManager<UserModel> userManager)
|
|
||||||
{
|
|
||||||
_userManager = userManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[BindProperty]
|
|
||||||
public InputModel Input { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public class InputModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[EmailAddress]
|
|
||||||
public string Email { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
|
|
||||||
[DataType(DataType.Password)]
|
|
||||||
public string Password { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[DataType(DataType.Password)]
|
|
||||||
[Display(Name = "Confirm password")]
|
|
||||||
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
|
|
||||||
public string ConfirmPassword { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[Required]
|
|
||||||
public string Code { get; set; }
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
public IActionResult OnGet(string code = null)
|
|
||||||
{
|
|
||||||
if (code == null)
|
|
||||||
{
|
|
||||||
return BadRequest("A code must be supplied for password reset.");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Input = new InputModel
|
|
||||||
{
|
|
||||||
Code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code))
|
|
||||||
};
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<IActionResult> OnPostAsync()
|
|
||||||
{
|
|
||||||
if (!ModelState.IsValid)
|
|
||||||
{
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
|
|
||||||
var user = await _userManager.FindByEmailAsync(Input.Email);
|
|
||||||
if (user == null)
|
|
||||||
{
|
|
||||||
// Don't reveal that the user does not exist
|
|
||||||
return RedirectToPage("./ResetPasswordConfirmation");
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await _userManager.ResetPasswordAsync(user, Input.Code, Input.Password);
|
|
||||||
if (result.Succeeded)
|
|
||||||
{
|
|
||||||
return RedirectToPage("./ResetPasswordConfirmation");
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var error in result.Errors)
|
|
||||||
{
|
|
||||||
ModelState.AddModelError(string.Empty, error.Description);
|
|
||||||
}
|
|
||||||
return Page();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
@page
|
|
||||||
@model ResetPasswordConfirmationModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Reset password confirmation";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h1>@ViewData["Title"]</h1>
|
|
||||||
<p>
|
|
||||||
Your password has been reset. Please <a asp-page="./Login">click here to log in</a>.
|
|
||||||
</p>
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[AllowAnonymous]
|
|
||||||
public class ResetPasswordConfirmationModel : PageModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public void OnGet()
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
@model string
|
|
||||||
|
|
||||||
@if (!String.IsNullOrEmpty(Model))
|
|
||||||
{
|
|
||||||
var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success";
|
|
||||||
<div class="alert alert-@statusMessageClass alert-dismissible" role="alert">
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
||||||
@Model
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@ -1 +0,0 @@
|
|||||||
@using PSTW_CentralSystem.Areas.Identity.Pages.Account
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
@page
|
|
||||||
@model ErrorModel
|
|
||||||
@{
|
|
||||||
ViewData["Title"] = "Error";
|
|
||||||
}
|
|
||||||
|
|
||||||
<h1 class="text-danger">Error.</h1>
|
|
||||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
|
||||||
|
|
||||||
@if (Model.ShowRequestId)
|
|
||||||
{
|
|
||||||
<p>
|
|
||||||
<strong>Request ID:</strong> <code>@Model.RequestId</code>
|
|
||||||
</p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<h3>Development Mode</h3>
|
|
||||||
<p>
|
|
||||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<strong>Development environment should not be enabled in deployed applications</strong>, as it can result in sensitive information from exceptions being displayed to end users. For local debugging, development environment can be enabled by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>, and restarting the application.
|
|
||||||
</p>
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
// Licensed to the .NET Foundation under one or more agreements.
|
|
||||||
// The .NET Foundation licenses this file to you under the MIT license.
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
using System.Diagnostics;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
|
||||||
|
|
||||||
namespace PSTW_CentralSystem.Areas.Identity.Pages
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
[AllowAnonymous]
|
|
||||||
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
|
||||||
public class ErrorModel : PageModel
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public string RequestId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
|
|
||||||
/// directly from your code. This API may change or be removed in future releases.
|
|
||||||
/// </summary>
|
|
||||||
public void OnGet()
|
|
||||||
{
|
|
||||||
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user