Compare commits
282 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 815769fab7 | |||
| eab7931951 | |||
| c48f61f40e | |||
| 8d3385f673 | |||
| f8e73bad3a | |||
| 2735cfdf99 | |||
| b8540d6f0b | |||
| 58b06df1c1 | |||
| 655f279d7a | |||
| 77e6700649 | |||
| adef552b79 | |||
| 495bc9b3ae | |||
| 19391a089e | |||
| 74119f7222 | |||
| 0a9417aa8a | |||
| 90ae22568b | |||
| ab79a16a6c | |||
| fce9d7b6c7 | |||
| 1485b8a160 | |||
| 754d8ac0ef | |||
| a437e62263 | |||
| ccef2721f6 | |||
| 4ebac3888e | |||
| fa75a383ba | |||
| d002954b69 | |||
| 8555a98e66 | |||
| c0e285545e | |||
| 14d013f2f1 | |||
| 4a1edba2c8 | |||
| c7a63270c7 | |||
| ca7e6c1c8f | |||
| e3bf644a0b | |||
| 20b9f1e1fd | |||
| d039e7d7da | |||
| 01ba9a6629 | |||
| 5298db78cb | |||
| 98ba700ef2 | |||
| bde383ced7 | |||
| 1922f78cef | |||
| d671db342a | |||
| 5e5d7280f4 | |||
| d1d08a612f | |||
| 549e61f5a8 | |||
| b0437d2fe8 | |||
| a84b6cb783 | |||
| 52a1a4b694 | |||
| ea623046b7 | |||
| 27a6d640a5 | |||
| eb61648667 | |||
|
|
b9a830f86a | ||
| 6ad2f11e9e | |||
|
|
4e7c5757e0 | ||
| c857468d91 | |||
|
|
efd69601ec | ||
|
|
5efe8e13c4 | ||
| 9958a4b1d1 | |||
| 416b283341 | |||
| 82192fde0d | |||
| 457ee8f5d3 | |||
| 755acb1a84 | |||
| a2ee35d462 | |||
| d9fe781343 | |||
| d5e5fe9a3f | |||
| 00660345fc | |||
| 9da24c144a | |||
| 6ee24de227 | |||
| 0b298c7965 | |||
|
|
19a9ade3eb | ||
| 1b6b25d7e7 | |||
| 43466c85e6 | |||
| 5ae31cd21b | |||
| 7e80ca5ef9 | |||
| b31d54bd8e | |||
|
|
6d7dc52724 | ||
| 0556e4763e | |||
| e220868d72 | |||
|
|
4248484877 | ||
| 138d88918f | |||
|
|
c0c2c59ea3 | ||
| ba48e513df | |||
|
|
90e56547ba | ||
|
|
8a39714a25 | ||
| c7b398374e | |||
|
|
be8084ca85 | ||
| 7f4d39d2dd | |||
|
|
695af0f339 | ||
| 58434ccc1d | |||
|
|
1400532680 | ||
|
|
872eb2363a | ||
|
|
ffdc93a4b7 | ||
| 6d7a04ae7c | |||
| bc4bb67ff8 | |||
|
|
2c9d8bc4da | ||
| 9225a489e5 | |||
| 5b0553527b | |||
|
|
95db83ab3e | ||
| 76cbe99b98 | |||
| e8a290c4e4 | |||
| 7a8be354ed | |||
| 2887cd3f4e | |||
| 526acc406b | |||
|
|
db408bb2db | ||
|
|
32d8689eb1 | ||
| 00b0e6eb0c | |||
| 2c7ee5aca9 | |||
|
|
9e3539caa6 | ||
| 304b5e6d94 | |||
|
|
aa316b94ae | ||
| 3d6647b87d | |||
|
|
802a81357e | ||
|
|
e131348fb8 | ||
| 6e44454808 | |||
| d6d151a3c7 | |||
|
|
c338ee7c6c | ||
| 7a08ac374a | |||
| 824b6ea2ff | |||
|
|
bdb17f766c | ||
|
|
f9bc489b8e | ||
| cace9c396a | |||
| 4de1a6633c | |||
|
|
de191e7943 | ||
|
|
1d02b87c1d | ||
| 5e73be28c3 | |||
| 530f132256 | |||
| f05c23f259 | |||
| 11710f440d | |||
| 1f813b88bb | |||
| 0a3b0fb214 | |||
| bad7c62bbb | |||
| f4280b9645 | |||
| 59f45bb4bd | |||
| 7c66571264 | |||
|
|
4a7368b4d7 | ||
| 00a040c0ed | |||
| 213fd2b460 | |||
| 61f053175f | |||
| e5e1414826 | |||
| c45251e08f | |||
| 2c10b89147 | |||
| 58f3fc4c48 | |||
|
|
cf4b5fe8b5 | ||
|
|
f73eecdf77 | ||
|
|
54367ee3cd | ||
| 9f1283f7c9 | |||
| bf6e53b56e | |||
| ce2a741450 | |||
| 291d4dabdb | |||
| a1db9175a3 | |||
| ed04371e3c | |||
| 21a950f519 | |||
| cc99419e49 | |||
| 6c2923ca1a | |||
| 53bbde0f0b | |||
| 2d474477fa | |||
| 1e49f979aa | |||
| 02197f4c1f | |||
| 9ed5e6b6a5 | |||
| d316a42a5e | |||
| 9857983174 | |||
| 0225c22696 | |||
| e43205c08e | |||
| a3dc02fb80 | |||
| e78e1c979e | |||
| ea9038d6b8 | |||
| 1542750302 | |||
| 37ea90be65 | |||
| ea54070d54 | |||
| ce941d5d53 | |||
| 48636528b1 | |||
| c36a1e6094 | |||
| dd23c13644 | |||
| 1411c0c830 | |||
| aaa93edb89 | |||
| 51150e5377 | |||
| fdc3c81574 | |||
| 00396e4b0f | |||
| 6018c3df2c | |||
| f4fc5dc103 | |||
| e64e288eac | |||
| f6f2990c8f | |||
| 98161fd740 | |||
| 7e5f3e3b36 | |||
| f8be5be392 | |||
| 3ec456afbd | |||
| a0d84272aa | |||
| 26aaff2d2b | |||
| eab78d321f | |||
| 4afac02583 | |||
| 18ecaedda8 | |||
| eba3f6c5c4 | |||
| b7ec8ab7af | |||
| f4125a09ed | |||
| 11e8e84064 | |||
| 6150baa25d | |||
| 9d9f931642 | |||
| 38a9e623a8 | |||
| 976a83bbcc | |||
| 2d574ad949 | |||
| dde2da8d41 | |||
| 00264dd2d2 | |||
| a7ffd18754 | |||
| e1cf4fc885 | |||
| 0ff7592b01 | |||
| df2ec7e88c | |||
| 98ae0e1ad6 | |||
| ebf8008b22 | |||
| 788ab1aa5a | |||
| 41d1c8e1af | |||
| 02f8ce4cd8 | |||
| 01814cc26f | |||
| 20c01825f9 | |||
| e28117b578 | |||
| 8cf3fd9b14 | |||
| 31254a6460 | |||
| 2cdf176620 | |||
| 79fb9649d7 | |||
| ea4666690e | |||
| 43962646bb | |||
| ca46776473 | |||
| 0f2b065c66 | |||
| 9214a081d3 | |||
| 646136aade | |||
| 1c4e1a32c6 | |||
| 531106d90d | |||
| 92ce4b6267 | |||
| 4d517fdd6c | |||
| 8b7a4d5390 | |||
| a6572b6b22 | |||
| e478637fba | |||
| d8850efed7 | |||
| 1e1b65dce6 | |||
| 85fce16dbe | |||
| d9e67e6139 | |||
| 5f4e8c6c22 | |||
| f8ef2b449c | |||
| 9b31a50115 | |||
| d7ea028d82 | |||
| 9324f61d05 | |||
| d1682750dc | |||
| 38c4629302 | |||
| 6e9ec353b9 | |||
| 0d511921e8 | |||
| 0bf343b65d | |||
| 52421e9693 | |||
| 5576acc67d | |||
| 5a786d221d | |||
| 082be76c51 | |||
| 292f516e33 | |||
| 8ddf9752f4 | |||
| b5d3829457 | |||
| a1e2bf6ae0 | |||
| 3610536233 | |||
| 3cfcfd7a3a | |||
| 45a94c99fc | |||
| 970ece5602 | |||
| 5db84d96a2 | |||
| 7cbc870f78 | |||
| 907a171616 | |||
| 8135725180 | |||
| 391a359a9f | |||
| 348206d306 | |||
| 961b8c6db5 | |||
| cf714b92f6 | |||
| 18d053800a | |||
| fd34ac4822 | |||
| 0ee2817376 | |||
| 0168aa1929 | |||
| caac6c0d39 | |||
| 05a07ca5ea | |||
| 72dbbde075 | |||
| b378c73152 | |||
| 09fa8fc604 | |||
| d7e93a98b3 | |||
| 87c447e935 | |||
| b6b8b4a705 | |||
| 630a3175ce | |||
| 5910e4f15f | |||
| 5569a72e68 | |||
| 54b2affe28 | |||
| 3f3ad980b9 | |||
| 8dc8dd90da | |||
| f51c8ffe65 |
5
.config/dotnet-tools.json
Normal file
5
.config/dotnet-tools.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {}
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -361,3 +361,6 @@ MigrationBackup/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
# Ignore local publish test builds
|
||||
publish-test/
|
||||
93
Areas/Bookings/Controllers/BookingsController.cs
Normal file
93
Areas/Bookings/Controllers/BookingsController.cs
Normal file
@ -0,0 +1,93 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PSTW_CentralSystem.DBContext;
|
||||
|
||||
namespace PSTW_CentralSystem.Areas.Bookings.Controllers
|
||||
{
|
||||
[Area("Bookings")]
|
||||
[Authorize] // require login for everything here
|
||||
public class BookingsController : Controller
|
||||
{
|
||||
private readonly CentralSystemContext _db;
|
||||
private readonly ILogger<BookingsController> _logger;
|
||||
|
||||
public BookingsController(CentralSystemContext db, ILogger<BookingsController> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
private int? GetCurrentUserId()
|
||||
{
|
||||
var idStr = User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
return int.TryParse(idStr, out var id) ? id : (int?)null;
|
||||
}
|
||||
|
||||
// DB-backed manager check (NO Identity roles here)
|
||||
private Task<bool> IsManagerAsync()
|
||||
{
|
||||
var me = GetCurrentUserId();
|
||||
if (me is null) return Task.FromResult(false);
|
||||
return _db.BookingManager.AsNoTracking()
|
||||
.AnyAsync(x => x.UserId == me.Value && x.IsActive);
|
||||
}
|
||||
|
||||
private Task<bool> AnyManagersAsync()
|
||||
=> _db.BookingManager.AsNoTracking().AnyAsync();
|
||||
|
||||
private async Task<IActionResult?> RequireManagerOrForbidAsync()
|
||||
{
|
||||
if (await IsManagerAsync()) return null;
|
||||
return Forbid(); // or RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// ---------- pages ----------
|
||||
public IActionResult Index() => View();
|
||||
|
||||
// Manager-only (rooms list/maintenance)
|
||||
public async Task<IActionResult> Room()
|
||||
{
|
||||
var gate = await RequireManagerOrForbidAsync();
|
||||
if (gate is not null) return gate;
|
||||
return View();
|
||||
}
|
||||
|
||||
// Manager-only (create/edit room)
|
||||
public async Task<IActionResult> RoomsCreate()
|
||||
{
|
||||
var gate = await RequireManagerOrForbidAsync();
|
||||
if (gate is not null) return gate;
|
||||
return View();
|
||||
}
|
||||
|
||||
// Everyone can view the calendar
|
||||
public IActionResult Calendar() => View();
|
||||
|
||||
// Managers page:
|
||||
// - Bootstrap: if no managers exist yet, allow any authenticated user to seed.
|
||||
// - Otherwise: only managers.
|
||||
public async Task<IActionResult> Managers()
|
||||
{
|
||||
if (!await AnyManagersAsync())
|
||||
{
|
||||
ViewBag.Bootstrap = true; // optional UI hint
|
||||
return View();
|
||||
}
|
||||
|
||||
var gate = await RequireManagerOrForbidAsync();
|
||||
if (gate is not null) return gate;
|
||||
|
||||
ViewBag.Bootstrap = false;
|
||||
return View();
|
||||
}
|
||||
|
||||
// Create/Edit booking (JS loads data by id)
|
||||
public IActionResult Create(int? id)
|
||||
{
|
||||
ViewBag.Id = id;
|
||||
return View();
|
||||
}
|
||||
}
|
||||
}
|
||||
27
Areas/Bookings/Models/BookingManager.cs
Normal file
27
Areas/Bookings/Models/BookingManager.cs
Normal file
@ -0,0 +1,27 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PSTW_CentralSystem.Areas.Bookings.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Dynamic list of users who have "manager" powers in the Room Booking module.
|
||||
/// Keep it simple (global scope); you can extend with CompanyId/DepartmentId/RoomId later.
|
||||
/// </summary>
|
||||
[Table("booking_managers")]
|
||||
public class BookingManager
|
||||
{
|
||||
[Key]
|
||||
public int BookingManagerId { get; set; }
|
||||
|
||||
/// <summary>FK → aspnetusers(Id) (int)</summary>
|
||||
[Required]
|
||||
public int UserId { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
[Required]
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public int? CreatedByUserId { get; set; }
|
||||
}
|
||||
}
|
||||
88
Areas/Bookings/Models/BookingsModel.cs
Normal file
88
Areas/Bookings/Models/BookingsModel.cs
Normal file
@ -0,0 +1,88 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PSTW_CentralSystem.Areas.Bookings.Models
|
||||
{
|
||||
public enum BookingStatus
|
||||
{
|
||||
Pending = 0,
|
||||
Approved = 1,
|
||||
Rejected = 2,
|
||||
Cancelled = 3
|
||||
}
|
||||
|
||||
[Table("bookings")]
|
||||
public class Booking : IValidatableObject
|
||||
{
|
||||
[Key]
|
||||
[Column("BookingId")]
|
||||
public int BookingId { get; set; }
|
||||
|
||||
/// <summary>FK → aspnetusers(Id) (int)</summary>
|
||||
[Required]
|
||||
public int RequestedByUserId { get; set; }
|
||||
|
||||
/// <summary>If booking on behalf of someone else; else null.</summary>
|
||||
public int? TargetUserId { get; set; }
|
||||
|
||||
/// <summary>Snapshot of org at submission time.</summary>
|
||||
public int? DepartmentId { get; set; } // FK → departments(DepartmentId)
|
||||
public int? CompanyId { get; set; } // FK → companies(CompanyId)
|
||||
|
||||
/// <summary>Room being booked.</summary>
|
||||
[Required]
|
||||
public int RoomId { get; set; } // FK → rooms(RoomId)
|
||||
|
||||
[Required, StringLength(150)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(300)]
|
||||
public string? Purpose { get; set; }
|
||||
|
||||
/// <summary>Use UTC to avoid TZ headaches; map to DATETIME in MySQL.</summary>
|
||||
[Required]
|
||||
public DateTime StartUtc { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime EndUtc { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Note { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[Required]
|
||||
public DateTime LastUpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
|
||||
[Required]
|
||||
public BookingStatus CurrentStatus { get; set; } = BookingStatus.Pending;
|
||||
|
||||
// ---- validation ----
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext _)
|
||||
{
|
||||
if (EndUtc <= StartUtc)
|
||||
yield return new ValidationResult("End time must be after start time.",
|
||||
new[] { nameof(EndUtc) });
|
||||
|
||||
if ((EndUtc - StartUtc).TotalMinutes < 10)
|
||||
yield return new ValidationResult("Minimum booking duration is 10 minutes.",
|
||||
new[] { nameof(StartUtc), nameof(EndUtc) });
|
||||
}
|
||||
}
|
||||
|
||||
[Table("rooms")]
|
||||
public class Room
|
||||
{
|
||||
[Key] public int RoomId { get; set; }
|
||||
|
||||
[Required, StringLength(120)]
|
||||
public string RoomName { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(40)]
|
||||
public string? LocationCode { get; set; }
|
||||
|
||||
public int? Capacity { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
}
|
||||
721
Areas/Bookings/Views/Bookings/Calendar.cshtml
Normal file
721
Areas/Bookings/Views/Bookings/Calendar.cshtml
Normal file
@ -0,0 +1,721 @@
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Bookings Calendar";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<style>
|
||||
.calendar-wrap {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,.08);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar .left, .toolbar .right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.legend span {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 3px;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.free {
|
||||
background: #d9ead3
|
||||
}
|
||||
|
||||
.partial {
|
||||
background: #ffe599
|
||||
}
|
||||
|
||||
.busy {
|
||||
background: #f4cccc
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7,1fr);
|
||||
gap: 6px
|
||||
}
|
||||
|
||||
.dow {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
padding: 6px 0;
|
||||
color: #555
|
||||
}
|
||||
|
||||
.cell {
|
||||
min-height: 110px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer
|
||||
}
|
||||
|
||||
.cell.other-month {
|
||||
opacity: .5
|
||||
}
|
||||
|
||||
.cell .daynum {
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
height: 6px;
|
||||
border-radius: 4px;
|
||||
margin-top: 6px;
|
||||
background: #eee;
|
||||
overflow: hidden
|
||||
}
|
||||
|
||||
.status-bar > div {
|
||||
height: 100%
|
||||
}
|
||||
|
||||
.cell.free .status-bar > div {
|
||||
background: #b7dfa6
|
||||
}
|
||||
|
||||
.cell.partial .status-bar > div {
|
||||
background: #ffd966
|
||||
}
|
||||
|
||||
.cell.busy .status-bar > div {
|
||||
background: #ea9999
|
||||
}
|
||||
|
||||
.controls select {
|
||||
border-radius: 8px;
|
||||
font-size: 13px
|
||||
}
|
||||
|
||||
.chip {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: #e9ecef;
|
||||
display: inline-block;
|
||||
margin-top: auto;
|
||||
width: max-content
|
||||
}
|
||||
|
||||
/* ===== Day Board (separate header row above timeline) ===== */
|
||||
.dayboard {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr; /* header row, then scrollable body */
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.db-head {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr; /* left spacer, right room headers */
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.db-head-left {
|
||||
background: #fafbfc;
|
||||
border-right: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.db-head-right {
|
||||
overflow: hidden;
|
||||
}
|
||||
/* no scroll; pure header row */
|
||||
.col-header {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
}
|
||||
|
||||
.col-header div {
|
||||
padding: 8px 10px;
|
||||
font-weight: 600;
|
||||
border-right: 1px solid #f1f1f1;
|
||||
white-space: nowrap;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.db-body {
|
||||
display: grid;
|
||||
grid-template-columns: 90px 1fr; /* left time sheet, right bookings sheet */
|
||||
height: 60vh; /* viewport height for scrolling; tweak if needed */
|
||||
}
|
||||
|
||||
.db-time {
|
||||
background: #fafbfc;
|
||||
border-right: 1px solid #e9ecef;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.db-time::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.time-slot {
|
||||
height: 32px; /* SLOT_PX: must match JS */
|
||||
border-bottom: 1px dashed #eee;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.db-cols {
|
||||
position: relative;
|
||||
overflow: auto; /* the pane that scrolls bookings vertically */
|
||||
}
|
||||
|
||||
.canvas { /* gives the bookings pane its content height */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.col-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
}
|
||||
|
||||
.col-grid .gcol {
|
||||
border-right: 1px solid #f1f1f1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.hline {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: #eee
|
||||
}
|
||||
|
||||
.nowline {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: #ff6;
|
||||
z-index: 3
|
||||
}
|
||||
|
||||
.booking {
|
||||
position: absolute;
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
border-radius: 8px;
|
||||
background: #d1fae5;
|
||||
border: 1px solid #a7f3d0;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.06);
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
overflow: hidden
|
||||
}
|
||||
|
||||
.booking .t {
|
||||
font-weight: 700
|
||||
}
|
||||
|
||||
.booking .meta {
|
||||
font-size: 11px;
|
||||
color: #555
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="mb-0"></h2>
|
||||
<div class="d-flex gap-2">
|
||||
<a asp-area="Bookings" asp-controller="Bookings" asp-action="Index" class="btn btn-outline-secondary">Back to List</a>
|
||||
<a asp-area="Bookings" asp-controller="Bookings" asp-action="Create" class="btn btn-primary">Create New</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="alerts"></div>
|
||||
|
||||
<div class="calendar-wrap">
|
||||
<div class="toolbar">
|
||||
<div class="left">
|
||||
<button id="btnPrev" class="btn btn-sm btn-outline-secondary">◀</button>
|
||||
<button id="btnToday" class="btn btn-sm btn-outline-secondary">Today</button>
|
||||
<button id="btnNext" class="btn btn-sm btn-outline-secondary">▶</button>
|
||||
<strong id="lblMonth" class="ms-2"></strong>
|
||||
</div>
|
||||
<div class="right controls">
|
||||
<select id="ddlRoom" class="form-select form-select-sm" style="min-width:220px">
|
||||
<option value="">All rooms</option>
|
||||
</select>
|
||||
<select id="ddlUser" class="form-select form-select-sm" style="min-width:220px">
|
||||
<option value="">All users</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend mb-2">
|
||||
<span class="free"></span> Free
|
||||
<span class="partial"></span> Partially booked
|
||||
<span class="busy"></span> Busy
|
||||
</div>
|
||||
|
||||
<div class="grid mb-2" id="dowRow"></div>
|
||||
<div class="grid" id="calGrid"></div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Day Board Modal ===== -->
|
||||
<div class="modal fade" id="dayBoardModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-fullscreen-lg-down modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="board-toolbar w-100">
|
||||
<h5 class="modal-title me-auto" id="boardTitle">Schedule</h5>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="dayboard">
|
||||
<!-- Header row (room names ABOVE the timeline) -->
|
||||
<div class="db-head">
|
||||
<div class="db-head-left"></div>
|
||||
<div class="db-head-right">
|
||||
<div id="colsHeader" class="col-header"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body row (time sheet left, bookings right) -->
|
||||
<div class="db-body">
|
||||
<div class="db-time" id="timeCol"></div>
|
||||
<div class="db-cols" id="colsScroll">
|
||||
<div class="canvas" id="canvas">
|
||||
<div class="col-grid" id="colGrid"></div>
|
||||
<div class="nowline d-none" id="nowLine"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted mt-2" id="boardFiltersInfo" style="font-size:.9rem;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
(() => {
|
||||
// --- Constants / API ---
|
||||
const api = `${window.location.origin}/api/BookingsApi`;
|
||||
const calendarScope = "calendar";
|
||||
|
||||
// --- Small helpers ---
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
function startOfMonth(d) { const x = new Date(d); x.setDate(1); x.setHours(0, 0, 0, 0); return x; }
|
||||
function startOfWeek(d) { const x = new Date(d); const day = (x.getDay() + 7) % 7; x.setDate(x.getDate() - day); x.setHours(0, 0, 0, 0); return x; }
|
||||
function addDays(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; }
|
||||
function ymd(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }
|
||||
function toUtcIso(d) { const off = d.getTimezoneOffset(); return new Date(d.getTime() - off * 60000).toISOString(); }
|
||||
function fmtLocal(d) { const x = new Date(d); return `${pad(x.getHours())}:${pad(x.getMinutes())}`; }
|
||||
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, m => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[m])); }
|
||||
function alertMsg(msg) {
|
||||
document.getElementById("alerts").innerHTML = `
|
||||
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||
${esc(String(msg))}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>`;
|
||||
console.warn("[Bookings Calendar]", msg);
|
||||
}
|
||||
window.addEventListener("error", e => alertMsg(e.message));
|
||||
window.addEventListener("unhandledrejection", e => alertMsg(e.reason?.message || e.reason));
|
||||
|
||||
// --- DOM refs ---
|
||||
const lblMonth = document.getElementById("lblMonth");
|
||||
const calGrid = document.getElementById("calGrid");
|
||||
const dowRow = document.getElementById("dowRow");
|
||||
const ddlRoom = document.getElementById("ddlRoom");
|
||||
const ddlUser = document.getElementById("ddlUser");
|
||||
|
||||
// Day board elements
|
||||
const boardModalEl = document.getElementById("dayBoardModal"); let boardModal;
|
||||
const boardTitle = document.getElementById("boardTitle");
|
||||
const boardFilters = document.getElementById("boardFiltersInfo");
|
||||
const timeCol = document.getElementById("timeCol");
|
||||
const colsHead = document.getElementById("colsHeader");
|
||||
const colsScroll = document.getElementById("colsScroll");
|
||||
const canvas = document.getElementById("canvas");
|
||||
const colGrid = document.getElementById("colGrid");
|
||||
const nowLine = document.getElementById("nowLine");
|
||||
|
||||
// DOW header (once)
|
||||
if (!dowRow.dataset.done) {
|
||||
["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].forEach(d => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "dow";
|
||||
div.textContent = d;
|
||||
dowRow.appendChild(div);
|
||||
});
|
||||
dowRow.dataset.done = "1";
|
||||
}
|
||||
|
||||
// --- Lookups (rooms + users) ---
|
||||
let userMap = new Map(), roomMap = new Map(), users = [], rooms = [];
|
||||
async function loadLookups() {
|
||||
try {
|
||||
const r = await fetch(`${api}?scope=${calendarScope}&lookups=1`);
|
||||
if (!r.ok) throw new Error(`Lookups ${r.status} ${r.statusText}`);
|
||||
const js = await r.json();
|
||||
rooms = js.rooms ?? [];
|
||||
users = js.users ?? [];
|
||||
} catch (e) {
|
||||
alertMsg(e.message);
|
||||
rooms = [];
|
||||
users = [];
|
||||
}
|
||||
|
||||
// Fill selects
|
||||
ddlRoom.innerHTML = '<option value="">All rooms</option>';
|
||||
rooms.forEach(r => {
|
||||
const id = r.roomId;
|
||||
const name = r.roomName ?? `Room ${id}`;
|
||||
if (id == null) return;
|
||||
const opt = document.createElement("option");
|
||||
opt.value = id;
|
||||
opt.textContent = name;
|
||||
ddlRoom.appendChild(opt);
|
||||
});
|
||||
|
||||
ddlUser.innerHTML = '<option value="">All users</option>';
|
||||
users.forEach(u => {
|
||||
const id = u.Id ?? u.id;
|
||||
const name = u.UserName ?? u.userName ?? "";
|
||||
const email = u.Email ?? "";
|
||||
const opt = document.createElement("option");
|
||||
opt.value = id;
|
||||
opt.textContent = email ? `${name} (${email})` : name;
|
||||
ddlUser.appendChild(opt);
|
||||
});
|
||||
|
||||
userMap = new Map(
|
||||
users.map(u => [
|
||||
Number(u.Id ?? u.id),
|
||||
(u.UserName ?? u.userName ?? "") + (u.Email ? ` (${u.Email})` : "")
|
||||
])
|
||||
);
|
||||
roomMap = new Map(
|
||||
rooms.map(r => {
|
||||
const id = Number(r.roomId ?? r.RoomId);
|
||||
const name = r.roomName ?? r.Name ?? r.RoomName ?? `Room ${id}`;
|
||||
return [id, name];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// --- Data fetching for month grid ---
|
||||
let curMonth = startOfMonth(new Date()), currentData = [];
|
||||
async function fetchGridData(month) {
|
||||
const gridStart = startOfWeek(startOfMonth(month));
|
||||
const gridEnd = addDays(gridStart, 6 * 7 - 1);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("scope", calendarScope); // <<— calendar scope (bypass RBAC for reads)
|
||||
params.set("from", toUtcIso(gridStart));
|
||||
params.set("to", toUtcIso(gridEnd));
|
||||
params.set("pageSize", "10000");
|
||||
if (ddlRoom.value) params.set("roomId", ddlRoom.value);
|
||||
if (ddlUser.value) params.set("userId", ddlUser.value);
|
||||
|
||||
const r = await fetch(`${api}?${params.toString()}`);
|
||||
if (!r.ok) throw new Error(`API ${r.status}: ${await r.text().catch(() => r.statusText)}`);
|
||||
let data = await r.json();
|
||||
|
||||
// client-side refine: drop Cancelled and apply user filter (requested/target)
|
||||
const userSel = ddlUser.value ? Number(ddlUser.value) : null;
|
||||
data = data.filter(b => {
|
||||
const status = (b.status ?? "").toString();
|
||||
if (status === "Cancelled") return false;
|
||||
if (userSel !== null) {
|
||||
const rq = Number(b.requestedByUserId ?? b.UserId ?? b.userId ?? NaN);
|
||||
const tg = Number(b.targetUserId ?? b.TargetUserId ?? NaN);
|
||||
if (!(rq === userSel || tg === userSel)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return { gridStart, data };
|
||||
}
|
||||
|
||||
// --- Month grid: skeleton & render ---
|
||||
function renderSkeletonGrid(month) {
|
||||
lblMonth.textContent = month.toLocaleString(undefined, { month: "long", year: "numeric" });
|
||||
calGrid.innerHTML = "";
|
||||
const gridStart = startOfWeek(startOfMonth(month));
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = addDays(gridStart, i);
|
||||
const cell = document.createElement("div");
|
||||
cell.className = `cell free ${d.getMonth() === month.getMonth() ? "" : "other-month"}`;
|
||||
cell.innerHTML = `
|
||||
<div class="daynum">${d.getDate()}</div>
|
||||
<div class="status-bar"><div style="width:0%"></div></div>
|
||||
<div class="chip">Free</div>`;
|
||||
cell.addEventListener("click", () => openDayBoard(d));
|
||||
calGrid.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
function computeDailyOccupancy(data, gridStart) {
|
||||
const start = new Date(gridStart), end = addDays(start, 41);
|
||||
const dayMap = new Map();
|
||||
|
||||
for (let d = new Date(start); d <= end; d = addDays(d, 1)) {
|
||||
dayMap.set(ymd(d), { minutes: 0, items: [], date: new Date(d) });
|
||||
}
|
||||
|
||||
for (const b of data) {
|
||||
const s = new Date(b.startUtc), e = new Date(b.endUtc);
|
||||
if (e < start || s > end) continue;
|
||||
|
||||
let cur = new Date(Math.max(s, start)); cur.setHours(0, 0, 0, 0);
|
||||
while (cur <= end) {
|
||||
const dayStart = new Date(cur), dayEnd = new Date(cur); dayEnd.setHours(23, 59, 59, 999);
|
||||
const os = new Date(Math.max(s, dayStart)), oe = new Date(Math.min(e, dayEnd));
|
||||
const mins = Math.max(0, Math.ceil((oe - os) / 60000));
|
||||
if (mins > 0) {
|
||||
const key = ymd(cur), entry = dayMap.get(key);
|
||||
entry.minutes += Math.min(mins, 1440);
|
||||
if (entry.items.length < 5) entry.items.push({ full: b, start: os, end: oe });
|
||||
}
|
||||
cur = addDays(cur, 1);
|
||||
if (cur > e) break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, entry] of dayMap) {
|
||||
const pct = Math.max(0, Math.min(1, entry.minutes / 1440));
|
||||
entry.pct = pct;
|
||||
entry.className = pct === 0 ? "free" : (pct <= 0.6 ? "partial" : "busy");
|
||||
}
|
||||
return { start, dayMap };
|
||||
}
|
||||
|
||||
let occCache = null;
|
||||
function renderGrid(month) {
|
||||
lblMonth.textContent = month.toLocaleString(undefined, { month: "long", year: "numeric" });
|
||||
calGrid.innerHTML = "";
|
||||
const start = occCache.start;
|
||||
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = addDays(start, i), key = ymd(d);
|
||||
const entry = occCache.dayMap.get(key) || { pct: 0, className: "free", items: [], date: d };
|
||||
|
||||
const cell = document.createElement("div");
|
||||
cell.className = `cell ${entry.className} ${d.getMonth() === month.getMonth() ? "" : "other-month"}`;
|
||||
cell.title = entry.items.length ? `${entry.items.length} booking(s)` : "Free day";
|
||||
cell.innerHTML = `
|
||||
<div class="daynum">${d.getDate()}</div>
|
||||
<div class="status-bar"><div style="width:${Math.round(entry.pct * 100)}%"></div></div>
|
||||
<div class="chip">${entry.items.length ? `${entry.items.length} booking${entry.items.length > 1 ? "s" : ""}` : "Free"}</div>`;
|
||||
cell.addEventListener("click", () => openDayBoard(d));
|
||||
calGrid.appendChild(cell);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Day board (rooms-only timeline) ---
|
||||
const DAY_START_HOUR = 8, DAY_END_HOUR = 20, SLOT_MIN = 30, SLOT_PX = 32;
|
||||
const TOTAL_INTERVALS = ((DAY_END_HOUR - DAY_START_HOUR) * 60) / SLOT_MIN; // 24
|
||||
function getRoomId(b) { const v = [b.roomId, b.RoomId, b.roomID, b.room_id].find(x => x != null); const n = Number(v); return Number.isNaN(n) ? null : n; }
|
||||
function getUserId(b) { const v = [b.requestedByUserId, b.UserId, b.userId, b.targetUserId, b.TargetUserId, b.bookedByUserId, b.BookedByUserId, b.approvedByUserId, b.ApprovedByUserId].find(x => x != null); const n = Number(v); return Number.isNaN(n) ? null : n; }
|
||||
|
||||
function openDayBoard(dayDate) {
|
||||
boardTitle.textContent = `Schedule • ${dayDate.toLocaleDateString()}`;
|
||||
boardFilters.textContent = `Filters: ${ddlRoom.options[ddlRoom.selectedIndex]?.text || "All rooms"}`;
|
||||
|
||||
// Left time labels
|
||||
timeCol.innerHTML = "";
|
||||
const timeInner = document.createElement("div");
|
||||
timeInner.id = "timeInner";
|
||||
timeCol.appendChild(timeInner);
|
||||
for (let i = 0; i <= TOTAL_INTERVALS; i++) {
|
||||
const mins = i * SLOT_MIN;
|
||||
const hr = DAY_START_HOUR + Math.floor(mins / 60);
|
||||
const mm = (mins % 60) === 0 ? "00" : "30";
|
||||
const row = document.createElement("div");
|
||||
row.className = "time-slot";
|
||||
row.textContent = `${String(hr).padStart(2, "0")}:${mm}`;
|
||||
timeInner.appendChild(row);
|
||||
}
|
||||
|
||||
// Columns = rooms (respect filter)
|
||||
let columns = rooms
|
||||
.filter(r => !ddlRoom.value || String(r.roomId ?? r.RoomId) === ddlRoom.value)
|
||||
.map(r => {
|
||||
const id = Number(r.roomId ?? r.RoomId);
|
||||
const label = r.roomName ?? r.Name ?? r.RoomName ?? `Room ${id}`;
|
||||
return { key: id, label };
|
||||
});
|
||||
if (columns.length === 0) columns = [{ key: Number.MIN_SAFE_INTEGER, label: "Room" }];
|
||||
|
||||
colsHead.innerHTML = "";
|
||||
colsHead.style.gridTemplateColumns = `repeat(${columns.length},1fr)`;
|
||||
columns.forEach(c => {
|
||||
const h = document.createElement("div");
|
||||
h.textContent = c.label;
|
||||
colsHead.appendChild(h);
|
||||
});
|
||||
|
||||
canvas.style.height = `${TOTAL_INTERVALS * SLOT_PX}px`;
|
||||
|
||||
// Build grid columns
|
||||
colGrid.innerHTML = "";
|
||||
colGrid.style.gridTemplateColumns = `repeat(${columns.length},1fr)`;
|
||||
for (let i = 0; i < columns.length; i++) {
|
||||
const g = document.createElement("div");
|
||||
g.className = "gcol";
|
||||
colGrid.appendChild(g);
|
||||
const container = document.createElement("div");
|
||||
container.style.position = "relative";
|
||||
container.style.height = "100%";
|
||||
g.appendChild(container);
|
||||
}
|
||||
|
||||
// Horizontal lines
|
||||
[...canvas.querySelectorAll(".hline")].forEach(x => x.remove());
|
||||
for (let i = 0; i <= TOTAL_INTERVALS; i++) {
|
||||
const l = document.createElement("div");
|
||||
l.className = "hline";
|
||||
l.style.top = `${i * SLOT_PX}px`;
|
||||
canvas.appendChild(l);
|
||||
}
|
||||
|
||||
// Data rows for selected day
|
||||
const dayStart = new Date(dayDate); dayStart.setHours(DAY_START_HOUR, 0, 0, 0);
|
||||
const dayEnd = new Date(dayDate); dayEnd.setHours(DAY_END_HOUR, 0, 0, 0);
|
||||
const rows = (currentData || []).filter(b => {
|
||||
const s = new Date(b.startUtc), e = new Date(b.endUtc);
|
||||
return e >= dayStart && s <= dayEnd;
|
||||
});
|
||||
|
||||
function colIndexFor(b) {
|
||||
const id = getRoomId(b);
|
||||
const idx = columns.findIndex(c => c.key === id);
|
||||
return idx === -1 ? 0 : idx;
|
||||
}
|
||||
|
||||
function posPx(s, e) {
|
||||
const clipS = new Date(Math.max(s.getTime(), dayStart.getTime()));
|
||||
const clipE = new Date(Math.min(e.getTime(), dayEnd.getTime()));
|
||||
const minutesFromStart = (clipS.getTime() - dayStart.getTime()) / 60000; // 0..720
|
||||
const dur = (clipE.getTime() - clipS.getTime()) / 60000; // 0..720
|
||||
return {
|
||||
topPx: (minutesFromStart / SLOT_MIN) * SLOT_PX,
|
||||
hPx: (dur / SLOT_MIN) * SLOT_PX
|
||||
};
|
||||
}
|
||||
|
||||
for (const b of rows) {
|
||||
const s = new Date(b.startUtc), e = new Date(b.endUtc);
|
||||
const { topPx, hPx } = posPx(s, e);
|
||||
const idx = colIndexFor(b);
|
||||
const container = colGrid.children[idx].firstChild;
|
||||
|
||||
const roomName = roomMap.get(getRoomId(b)) ?? (b.roomName ?? b.RoomName ?? "Room");
|
||||
const whoName = userMap.get(getUserId(b) ?? -1) ?? (b.userName ?? b.UserName ?? "—");
|
||||
const title = b.title ?? roomName;
|
||||
const note = (b.note ?? b.description ?? b.Notes ?? "").toString();
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "booking";
|
||||
div.style.top = `${topPx}px`;
|
||||
div.style.height = `${hPx}px`;
|
||||
div.title = note ? `Notes: ${note}` : "";
|
||||
div.innerHTML = `
|
||||
<div class="t">${esc(title)}</div>
|
||||
<div class="meta">${esc(fmtLocal(s))}–${esc(fmtLocal(e))}</div>
|
||||
<div class="meta">By: ${esc(whoName)}</div>
|
||||
${note ? `<div class="meta">Notes: ${esc(note)}</div>` : ""}`;
|
||||
container.appendChild(div);
|
||||
}
|
||||
|
||||
// Now-line
|
||||
const isToday = ymd(new Date()) === ymd(dayDate);
|
||||
if (isToday) {
|
||||
const now = new Date();
|
||||
const mins = (now.getHours() * 60 + now.getMinutes()) - (DAY_START_HOUR * 60);
|
||||
const px = Math.max(0, Math.min(TOTAL_INTERVALS * SLOT_PX, (mins / SLOT_MIN) * SLOT_PX));
|
||||
nowLine.classList.remove("d-none");
|
||||
nowLine.style.top = `${px}px`;
|
||||
} else {
|
||||
nowLine.classList.add("d-none");
|
||||
}
|
||||
|
||||
// Scroll sync
|
||||
colsScroll.removeEventListener("scroll", onColsScroll);
|
||||
function onColsScroll() {
|
||||
timeInner.style.transform = `translateY(-${colsScroll.scrollTop}px)`;
|
||||
}
|
||||
colsScroll.addEventListener("scroll", onColsScroll, { passive: true });
|
||||
timeInner.style.transform = `translateY(-${colsScroll.scrollTop}px)`;
|
||||
timeCol.scrollTop = colsScroll.scrollTop;
|
||||
|
||||
(boardModal ||= new bootstrap.Modal(boardModalEl)).show();
|
||||
}
|
||||
|
||||
// --- Navigation / state ---
|
||||
document.getElementById("btnPrev").onclick = async () => { curMonth.setMonth(curMonth.getMonth() - 1); await redraw(); };
|
||||
document.getElementById("btnNext").onclick = async () => { curMonth.setMonth(curMonth.getMonth() + 1); await redraw(); };
|
||||
document.getElementById("btnToday").onclick = async () => { curMonth = startOfMonth(new Date()); await redraw(); };
|
||||
ddlRoom.onchange = redraw;
|
||||
ddlUser.onchange = redraw;
|
||||
|
||||
async function redraw() {
|
||||
renderSkeletonGrid(curMonth);
|
||||
try {
|
||||
const { gridStart, data } = await fetchGridData(curMonth);
|
||||
currentData = data;
|
||||
occCache = computeDailyOccupancy(currentData, gridStart);
|
||||
renderGrid(curMonth);
|
||||
} catch (e) {
|
||||
alertMsg(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Boot ---
|
||||
(async function init() {
|
||||
await loadLookups();
|
||||
await redraw();
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
}
|
||||
|
||||
541
Areas/Bookings/Views/Bookings/Create.cshtml
Normal file
541
Areas/Bookings/Views/Bookings/Create.cshtml
Normal file
@ -0,0 +1,541 @@
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
|
||||
@{
|
||||
|
||||
Layout = "_Layout";
|
||||
}
|
||||
|
||||
<style>
|
||||
.booking-wrap {
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.booking-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 6px 24px rgba(0,0,0,.08);
|
||||
border: 1px solid rgba(0,0,0,.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.booking-header {
|
||||
padding: 20px 24px;
|
||||
background: linear-gradient(180deg, rgba(248,249,250,1) 0%, rgba(255,255,255,1) 100%);
|
||||
border-bottom: 1px solid rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
.booking-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .02em;
|
||||
color: #4a5568;
|
||||
text-transform: uppercase;
|
||||
margin: 12px 0 14px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: .85rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.form-card {
|
||||
background: #fafafa;
|
||||
border: 1px dashed rgba(0,0,0,.08);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.actions-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 2;
|
||||
background: rgba(255,255,255,.9);
|
||||
backdrop-filter: saturate(180%) blur(6px);
|
||||
border-top: 1px solid rgba(0,0,0,.06);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.actions-bar .btn {
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.required-badge {
|
||||
font-size: .7rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
border-radius: 999px;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="booking-wrap container-fluid px-0">
|
||||
<div class="booking-card">
|
||||
<div class="booking-header d-flex align-items-center justify-content-between">
|
||||
<h2 class="m-0">Create Booking</h2>
|
||||
<div id="alerts" class="ms-3" style="min-height:38px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="booking-body">
|
||||
<form id="bookingForm" class="row g-3">
|
||||
<input type="hidden" id="Id" />
|
||||
|
||||
<!-- DETAILS -->
|
||||
<div class="col-12">
|
||||
<div class="section-title">Details</div>
|
||||
<div class="form-card">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="Title" class="form-label">Title <span class="required-badge">Required</span></label>
|
||||
<input id="Title" class="form-control" maxlength="150" placeholder="e.g., Weekly Project Sync" required />
|
||||
<div class="hint mt-1">Max 150 characters.</div>
|
||||
<div class="invalid-feedback">Title is required (max 150 chars).</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="RoomId" class="form-label">Room <span class="required-badge">Required</span></label>
|
||||
<select id="RoomId" class="form-select" required>
|
||||
<option value="">-- Select Room --</option>
|
||||
</select>
|
||||
<div class="hint mt-1">Only active rooms are shown.</div>
|
||||
<div class="invalid-feedback">Please select a room.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SCHEDULE -->
|
||||
<div class="col-12">
|
||||
<div class="section-title">Schedule</div>
|
||||
<div class="form-card">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-6">
|
||||
<label for="StartUtc" class="form-label">Start <span class="required-badge">Required</span></label>
|
||||
<input id="StartUtc" type="datetime-local" class="form-control" step="1800" required />
|
||||
<div class="hint mt-1">Local time; saved as UTC. 30-minute slots (e.g., 10:30, 11:00).</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="EndUtc" class="form-label">End <span class="required-badge">Required</span></label>
|
||||
<input id="EndUtc" type="datetime-local" class="form-control" step="1800" required />
|
||||
<div class="hint mt-1">Must be the same date as Start and after Start.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- REQUESTER -->
|
||||
<div class="col-12">
|
||||
<div class="section-title">Requester</div>
|
||||
<div class="form-card">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label for="RequestedByUserId" class="form-label">User <span class="required-badge">Required</span></label>
|
||||
<select id="RequestedByUserId" class="form-select" required>
|
||||
<option value="">-- Select User --</option>
|
||||
</select>
|
||||
<div class="hint mt-1">We’ll link the booking to this user.</div>
|
||||
<div class="invalid-feedback">Please select a user.</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label for="Note" class="form-label">Notes</label>
|
||||
<textarea id="Note" class="form-control" maxlength="300" rows="3" placeholder="Optional (purpose, guests, equipment needs)"></textarea>
|
||||
<div class="hint mt-1">Up to 300 characters. Included in Description server-side.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ACTIONS -->
|
||||
<div class="col-12">
|
||||
<div class="actions-bar d-flex justify-content-end gap-2">
|
||||
<a asp-area="Bookings" asp-controller="Bookings" asp-action="Index" class="btn btn-light border">Cancel</a>
|
||||
<button type="submit" id="submitBtn" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
// --- Constants ---
|
||||
const DAY_START_HOUR = 8; // 08:00
|
||||
const DAY_END_HOUR = 20; // 20:00
|
||||
const api = `${window.location.origin}/api/BookingsApi`;
|
||||
|
||||
// ---------- UTC <-> datetime-local helpers ----------
|
||||
function toLocalInputValue(iso) {
|
||||
if (!iso) return "";
|
||||
const hasTz = /(?:Z|[+\-]\d{2}:\d{2})$/i.test(String(iso));
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return "";
|
||||
const ms = hasTz
|
||||
? d.getTime() - d.getTimezoneOffset() * 60000
|
||||
: d.getTime();
|
||||
return new Date(ms).toISOString().slice(0, 16); // "YYYY-MM-DDTHH:mm"
|
||||
}
|
||||
|
||||
function fromLocalInputValue(localValue) {
|
||||
if (!localValue) return null;
|
||||
return new Date(localValue).toISOString(); // local -> UTC Z
|
||||
}
|
||||
|
||||
// ---------- UI helpers ----------
|
||||
function showAlert(type, msg) {
|
||||
document.getElementById("alerts").innerHTML = `
|
||||
<div class="alert alert-${type} alert-dismissible fade show mb-0" role="alert">
|
||||
${msg}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function getQueryId() {
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const qid = parseInt(qs.get("id") || "", 10);
|
||||
return Number.isInteger(qid) && qid > 0 ? qid : null;
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function formatLocalYmdHm(d) {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(
|
||||
d.getDate()
|
||||
)}T${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
// ---------- Time helpers ----------
|
||||
function snapTo30(localValue) {
|
||||
if (!localValue) return localValue;
|
||||
const d = new Date(localValue);
|
||||
if (isNaN(d)) return localValue;
|
||||
d.setSeconds(0, 0);
|
||||
const m = d.getMinutes();
|
||||
let snappedMin;
|
||||
if (m < 15) snappedMin = 0;
|
||||
else if (m < 45) snappedMin = 30;
|
||||
else {
|
||||
d.setHours(d.getHours() + 1);
|
||||
snappedMin = 0;
|
||||
}
|
||||
d.setMinutes(snappedMin);
|
||||
return formatLocalYmdHm(d);
|
||||
}
|
||||
|
||||
function enforceEndSameDay() {
|
||||
const startEl = document.getElementById("StartUtc");
|
||||
const endEl = document.getElementById("EndUtc");
|
||||
const sv = startEl.value,
|
||||
ev = endEl.value;
|
||||
if (!sv || !ev) return;
|
||||
|
||||
const sd = new Date(sv);
|
||||
const ed = new Date(ev);
|
||||
if (isNaN(sd) || isNaN(ed)) return;
|
||||
|
||||
const datesDiffer =
|
||||
sd.getFullYear() !== ed.getFullYear() ||
|
||||
sd.getMonth() !== ed.getMonth() ||
|
||||
sd.getDate() !== ed.getDate();
|
||||
|
||||
if (datesDiffer) {
|
||||
ed.setFullYear(sd.getFullYear(), sd.getMonth(), sd.getDate());
|
||||
endEl.value = formatLocalYmdHm(ed);
|
||||
showAlert("warning", "End date was adjusted to the same day as Start.");
|
||||
}
|
||||
}
|
||||
function applyEndMinMaxForStartDay() {
|
||||
const startEl = document.getElementById("StartUtc");
|
||||
const endEl = document.getElementById("EndUtc");
|
||||
const sv = startEl.value;
|
||||
|
||||
// compute local "now" rounded up to next :00/:30
|
||||
const nowLocalCeil = ceilToNext30Date(new Date());
|
||||
|
||||
if (!sv) {
|
||||
// if empty, at least prevent any past time today
|
||||
startEl.min = formatLocalYmdHm(nowLocalCeil);
|
||||
endEl.min = formatLocalYmdHm(nowLocalCeil);
|
||||
startEl.removeAttribute("max");
|
||||
endEl.removeAttribute("max");
|
||||
return;
|
||||
}
|
||||
|
||||
const sd = new Date(sv);
|
||||
if (isNaN(sd)) {
|
||||
startEl.min = formatLocalYmdHm(nowLocalCeil);
|
||||
endEl.min = formatLocalYmdHm(nowLocalCeil);
|
||||
startEl.removeAttribute("max");
|
||||
endEl.removeAttribute("max");
|
||||
return;
|
||||
}
|
||||
|
||||
// the fixed day window
|
||||
const dayStart = new Date(sd); dayStart.setHours(DAY_START_HOUR, 0, 0, 0);
|
||||
const dayEnd = new Date(sd); dayEnd.setHours(DAY_END_HOUR, 0, 0, 0);
|
||||
|
||||
// if the chosen start day is today, min is max(dayStart, nowLocalCeil); else min is dayStart
|
||||
const today = new Date();
|
||||
const isSameDay =
|
||||
sd.getFullYear() === today.getFullYear() &&
|
||||
sd.getMonth() === today.getMonth() &&
|
||||
sd.getDate() === today.getDate();
|
||||
|
||||
const startMin = isSameDay && nowLocalCeil > dayStart ? nowLocalCeil : dayStart;
|
||||
const latestStart = new Date(dayEnd.getTime() - 30 * 60000); // 19:30
|
||||
|
||||
// apply to Start picker
|
||||
startEl.min = formatLocalYmdHm(startMin);
|
||||
startEl.max = formatLocalYmdHm(latestStart);
|
||||
|
||||
// clamp Start into allowed window
|
||||
if (new Date(startEl.value) < startMin) startEl.value = formatLocalYmdHm(startMin);
|
||||
if (new Date(startEl.value) > latestStart) startEl.value = formatLocalYmdHm(latestStart);
|
||||
|
||||
// End is tied to (snapped) Start and capped by dayEnd
|
||||
const snappedStartStr = snapTo30(startEl.value);
|
||||
const snappedStart = new Date(snappedStartStr);
|
||||
|
||||
endEl.min = formatLocalYmdHm(snappedStart);
|
||||
endEl.max = formatLocalYmdHm(dayEnd);
|
||||
|
||||
// clamp End
|
||||
if (endEl.value) {
|
||||
const ed = new Date(endEl.value);
|
||||
if (ed < snappedStart) endEl.value = snappedStartStr;
|
||||
else if (ed > dayEnd) endEl.value = formatLocalYmdHm(dayEnd);
|
||||
}
|
||||
|
||||
enforceEndSameDay();
|
||||
}
|
||||
|
||||
|
||||
function ceilToNext30Date(d) {
|
||||
const x = new Date(d);
|
||||
x.setSeconds(0, 0);
|
||||
const m = x.getMinutes();
|
||||
if (m === 0 || m === 30) return x;
|
||||
if (m < 30) { x.setMinutes(30); return x; }
|
||||
x.setHours(x.getHours() + 1); x.setMinutes(0); return x;
|
||||
}
|
||||
|
||||
function normalizeTimes() {
|
||||
const startEl = document.getElementById("StartUtc");
|
||||
const endEl = document.getElementById("EndUtc");
|
||||
|
||||
if (startEl.value) startEl.value = snapTo30(startEl.value);
|
||||
if (endEl.value) endEl.value = snapTo30(endEl.value);
|
||||
|
||||
applyEndMinMaxForStartDay();
|
||||
|
||||
if (startEl.value && endEl.value) {
|
||||
const sd = new Date(startEl.value);
|
||||
const ed = new Date(endEl.value);
|
||||
if (ed < sd) {
|
||||
const newEnd = new Date(sd);
|
||||
newEnd.setMinutes(sd.getMinutes() + 30);
|
||||
endEl.value = formatLocalYmdHm(newEnd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Lookups ----------
|
||||
async function loadLookups(selectedRoomId, selectedUserId) {
|
||||
const res = await fetch(`${api}?lookups=1`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const { rooms = [], users = [] } = await res.json();
|
||||
|
||||
const pick = (obj, ...keys) => {
|
||||
for (const k of keys)
|
||||
if (obj[k] !== undefined && obj[k] !== null) return obj[k];
|
||||
return null;
|
||||
};
|
||||
|
||||
// Rooms
|
||||
const roomDdl = document.getElementById("RoomId");
|
||||
roomDdl.innerHTML = '<option value="">-- Select Room --</option>';
|
||||
rooms.forEach(r => {
|
||||
const id = pick(r, "RoomId", "roomId");
|
||||
if (id == null) return;
|
||||
|
||||
const name =
|
||||
(pick(r, "Name", "name", "RoomName", "roomName") ?? `Room ${id}`)
|
||||
.toString()
|
||||
.trim();
|
||||
const loc = pick(r, "LocationCode", "locationCode");
|
||||
const cap = pick(r, "Capacity", "capacity");
|
||||
|
||||
const opt = document.createElement("option");
|
||||
opt.value = id;
|
||||
opt.textContent = `${name}${loc ? ` @@ ${loc}` : ""}${cap ? ` — cap ${cap}` : ""
|
||||
}`;
|
||||
if (selectedRoomId && Number(selectedRoomId) === Number(id))
|
||||
opt.selected = true;
|
||||
roomDdl.appendChild(opt);
|
||||
});
|
||||
|
||||
// Users
|
||||
const userDdl = document.getElementById("RequestedByUserId");
|
||||
userDdl.innerHTML = '<option value="">-- Select User --</option>';
|
||||
users.forEach(u => {
|
||||
const id = pick(u, "Id", "id");
|
||||
if (id == null) return;
|
||||
|
||||
const name = pick(u, "UserName", "userName") ?? "";
|
||||
const email = pick(u, "Email", "email") ?? "";
|
||||
|
||||
const opt = document.createElement("option");
|
||||
opt.value = id;
|
||||
opt.textContent = email ? `${name} (${email})` : name;
|
||||
if (selectedUserId && Number(selectedUserId) === Number(id))
|
||||
opt.selected = true;
|
||||
userDdl.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Form submit ----------
|
||||
document.getElementById("bookingForm").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
normalizeTimes();
|
||||
|
||||
const id = document.getElementById("Id").value;
|
||||
const title = document.getElementById("Title").value.trim();
|
||||
const roomId = Number(document.getElementById("RoomId").value);
|
||||
const startLocal = document.getElementById("StartUtc").value;
|
||||
const endLocal = document.getElementById("EndUtc").value;
|
||||
const requestedByUserId = Number(
|
||||
document.getElementById("RequestedByUserId").value
|
||||
);
|
||||
const note = (document.getElementById("Note").value || "").trim();
|
||||
|
||||
if (!title || !roomId || !startLocal || !endLocal || !requestedByUserId) {
|
||||
showAlert("danger", "Please fill all required fields.");
|
||||
return;
|
||||
}
|
||||
|
||||
const sd = new Date(startLocal),
|
||||
ed = new Date(endLocal);
|
||||
const sameDay =
|
||||
sd.getFullYear() === ed.getFullYear() &&
|
||||
sd.getMonth() === ed.getMonth() &&
|
||||
sd.getDate() === ed.getDate();
|
||||
if (!sameDay) {
|
||||
showAlert("danger", "End must be on the same date as Start.");
|
||||
return;
|
||||
}
|
||||
|
||||
const startMinutes = sd.getHours() * 60 + sd.getMinutes();
|
||||
const endMinutes = ed.getHours() * 60 + ed.getMinutes();
|
||||
const latestStartMinutes = DAY_END_HOUR * 60 - 30;
|
||||
|
||||
if (startMinutes < DAY_START_HOUR * 60 || startMinutes > latestStartMinutes) {
|
||||
showAlert("danger", "Start must be between 08:00 and 19:30.");
|
||||
return;
|
||||
}
|
||||
if (endMinutes > DAY_END_HOUR * 60) {
|
||||
showAlert("danger", "End must be no later than 20:00.");
|
||||
return;
|
||||
}
|
||||
|
||||
const startUtc = fromLocalInputValue(startLocal);
|
||||
const endUtc = fromLocalInputValue(endLocal);
|
||||
if (new Date(endUtc) <= new Date(startUtc)) {
|
||||
showAlert("danger", "End time must be after Start time.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let res;
|
||||
if (id) {
|
||||
const payload = {
|
||||
RoomId: roomId,
|
||||
Title: title,
|
||||
StartUtc: startUtc,
|
||||
EndUtc: endUtc,
|
||||
Note: note || null
|
||||
};
|
||||
res = await fetch(`${api}?id=${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} else {
|
||||
const payload = {
|
||||
RoomId: roomId,
|
||||
RequestedByUserId: requestedByUserId,
|
||||
Title: title,
|
||||
StartUtc: startUtc,
|
||||
EndUtc: endUtc,
|
||||
Note: note || null,
|
||||
Description: note || null
|
||||
};
|
||||
res = await fetch(`${api}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const t = await res.text();
|
||||
showAlert("danger", (id ? "Update" : "Create") + " failed: " + t);
|
||||
return;
|
||||
}
|
||||
window.location.href = '@Url.Action("Index", "Bookings", new { area = "Bookings" })';
|
||||
} catch (err) {
|
||||
showAlert("danger", (id ? "Update" : "Create") + " failed: " + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Boot ----------
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const id = getQueryId();
|
||||
if (id) {
|
||||
const res = await fetch(`${api}?id=${encodeURIComponent(id)}`);
|
||||
if (!res.ok) {
|
||||
showAlert("danger", await res.text());
|
||||
return;
|
||||
}
|
||||
const b = await res.json();
|
||||
|
||||
document.getElementById("Id").value = b.id ?? b.Id ?? b.bookingId;
|
||||
document.getElementById("Title").value = b.title ?? "";
|
||||
document.getElementById("StartUtc").value = snapTo30(
|
||||
toLocalInputValue(b.startUtc)
|
||||
);
|
||||
document.getElementById("EndUtc").value = snapTo30(
|
||||
toLocalInputValue(b.endUtc)
|
||||
);
|
||||
document.getElementById("Note").value = b.note ?? "";
|
||||
|
||||
await loadLookups(b.roomId, b.requestedByUserId);
|
||||
const nowLocalCeil = ceilToNext30Date(new Date());
|
||||
document.getElementById("StartUtc").min = formatLocalYmdHm(nowLocalCeil);
|
||||
document.getElementById("EndUtc").min = formatLocalYmdHm(nowLocalCeil);
|
||||
|
||||
applyEndMinMaxForStartDay();
|
||||
normalizeTimes();
|
||||
|
||||
document.getElementById("submitBtn").textContent = "Update";
|
||||
} else {
|
||||
await loadLookups();
|
||||
const nowLocalCeil = ceilToNext30Date(new Date());
|
||||
document.getElementById("StartUtc").min = formatLocalYmdHm(nowLocalCeil);
|
||||
document.getElementById("EndUtc").min = formatLocalYmdHm(nowLocalCeil);
|
||||
applyEndMinMaxForStartDay();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
}
|
||||
861
Areas/Bookings/Views/Bookings/Index.cshtml
Normal file
861
Areas/Bookings/Views/Bookings/Index.cshtml
Normal file
@ -0,0 +1,861 @@
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using System.Security.Claims
|
||||
@{
|
||||
ViewData["Title"] = "Bookings";
|
||||
Layout = "_Layout";
|
||||
|
||||
var isMgr = User?.IsInRole("Manager") == true;
|
||||
var idStr = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
var meId = int.TryParse(idStr, out var tmp) ? tmp : 0;
|
||||
}
|
||||
|
||||
<style>
|
||||
.pagination-info {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,.1);
|
||||
padding: 25px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.table th, .table td {
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-green {
|
||||
background-color: #d9ead3 !important;
|
||||
}
|
||||
|
||||
.header-blue {
|
||||
background-color: #cfe2f3 !important;
|
||||
}
|
||||
|
||||
.header-orange {
|
||||
background-color: #fce5cd !important;
|
||||
}
|
||||
|
||||
.btn, input.form-control, select.form-control {
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Filters card */
|
||||
.filters-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(0,0,0,.06);
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,.06);
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12,1fr);
|
||||
gap: 12px 16px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.filters-field {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.filters-field label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.filters-field .form-control, .filters-field .form-select {
|
||||
height: 42px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filters-actions {
|
||||
grid-column: 1/-1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filters-actions .btn {
|
||||
height: 42px;
|
||||
padding: 0 16px;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@media (max-width:1200px) {
|
||||
.filters-field {
|
||||
grid-column: span 4;
|
||||
}
|
||||
}
|
||||
|
||||
@@media (max-width:768px) {
|
||||
.filters-field {
|
||||
grid-column: span 6;
|
||||
}
|
||||
}
|
||||
|
||||
@@media (max-width:576px) {
|
||||
.filters-field {
|
||||
grid-column: span 12;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fixed layout so <colgroup> widths are honored */
|
||||
.table-fixed {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
/* Column widths via <colgroup> classes */
|
||||
.col-title {
|
||||
width: 28ch;
|
||||
}
|
||||
|
||||
.col-notes {
|
||||
width: 10ch;
|
||||
}
|
||||
|
||||
.col-room {
|
||||
width: 12ch;
|
||||
}
|
||||
|
||||
.col-date {
|
||||
width: 14ch;
|
||||
}
|
||||
|
||||
.col-time {
|
||||
width: 16ch;
|
||||
}
|
||||
|
||||
.col-user {
|
||||
width: 14ch;
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
width: 150px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* Long text cells */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.td-title, .td-notes {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Compact actions */
|
||||
.actions-col {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Notes overlay ===== */
|
||||
.note-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,.35);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1080;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.note-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.2);
|
||||
max-width: 520px;
|
||||
width: 100%;
|
||||
padding: 18px 18px 14px;
|
||||
border: 1px solid rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
.note-card h6 {
|
||||
margin: 0 0 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.note-card .note-body {
|
||||
white-space: pre-wrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
--size: 34px;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0,0,0,.1);
|
||||
background: #fff;
|
||||
margin: 0 3px;
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<script>
|
||||
window.__ctx = { isManager: @(isMgr ? "true" : "false"), meId: @meId };
|
||||
</script>
|
||||
|
||||
<div id="app">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="mb-0"></h2>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="btnToggleFilters" type="button" class="btn btn-outline-secondary">Filters</button>
|
||||
<a asp-area="Bookings" asp-controller="Bookings" asp-action="Create" class="btn btn-primary">Create New</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="alerts"></div>
|
||||
|
||||
<!-- Status Tabs -->
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" :class="{ active: activeStatus === 'Pending' }" href="#" @@click.prevent="setStatus('Pending')">
|
||||
Pending <span class="badge bg-warning text-dark">{{ statusCounts.Pending }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" :class="{ active: activeStatus === 'Approved' }" href="#" @@click.prevent="setStatus('Approved')">
|
||||
Approved <span class="badge bg-success">{{ statusCounts.Approved }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" :class="{ active: activeStatus === 'Rejected' }" href="#" @@click.prevent="setStatus('Rejected')">
|
||||
Rejected <span class="badge bg-dark">{{ statusCounts.Rejected }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" :class="{ active: activeStatus === 'Cancelled' }" href="#" @@click.prevent="setStatus('Cancelled')">
|
||||
Cancelled <span class="badge bg-danger">{{ statusCounts.Cancelled }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="table-responsive table-container">
|
||||
<!-- Filters Panel -->
|
||||
<div id="filtersCard" class="filters-card">
|
||||
<div class="filters-grid">
|
||||
<div class="filters-field">
|
||||
<label for="fltFrom">From date</label>
|
||||
<input id="fltFrom" type="date" class="form-control" placeholder="dd/mm/yyyy" />
|
||||
</div>
|
||||
<div class="filters-field">
|
||||
<label for="fltTo">To date</label>
|
||||
<input id="fltTo" type="date" class="form-control" placeholder="dd/mm/yyyy" />
|
||||
</div>
|
||||
<div class="filters-field" id="userFilterField" @(isMgr ? "" : "style='display:none'")>
|
||||
<label for="fltUser">User</label>
|
||||
<select id="fltUser" class="form-select">
|
||||
<option value="">All users</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="filters-actions">
|
||||
<button id="btnApply" type="button" class="btn btn-primary">Apply</button>
|
||||
<button id="btnClear" type="button" class="btn btn-outline-secondary">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-bordered table-striped align-middle table-fixed" id="bookingsTable">
|
||||
<colgroup>
|
||||
<col class="col-title">
|
||||
<col class="col-notes">
|
||||
<col class="col-room">
|
||||
<col class="col-date"> <!-- new Date column -->
|
||||
<col class="col-time"> <!-- new Time column -->
|
||||
@if (isMgr)
|
||||
{
|
||||
<col class="col-user">
|
||||
}
|
||||
<col class="col-actions">
|
||||
</colgroup>
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="header-green">Title</th>
|
||||
<th class="header-green">Notes</th>
|
||||
<th class="header-blue">Room</th>
|
||||
<th class="header-blue">Date</th>
|
||||
<th class="header-blue">Time</th>
|
||||
@if (isMgr)
|
||||
{
|
||||
<th class="header-blue">User</th>
|
||||
}
|
||||
<th class="header-orange">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="@(isMgr ? 7 : 6)" class="text-center">Loading…</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pager -->
|
||||
<div id="pagerBar" class="d-flex justify-content-between align-items-center mt-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<button id="btnPrev" class="btn btn-outline-secondary btn-sm me-2" disabled>« Prev</button>
|
||||
<button id="btnNext" class="btn btn-outline-secondary btn-sm" disabled>Next »</button>
|
||||
</div>
|
||||
<div class="pagination-info small">
|
||||
<span id="pageInfo">Page 1 of 1</span>
|
||||
<span id="rangeInfo" class="ms-2">(Showing 0–0 of 0)</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<label for="selPageSize" class="me-2 small mb-0">Items per page</label>
|
||||
<select id="selPageSize" class="form-select form-select-sm" style="width:auto">
|
||||
<option value="5">5</option>
|
||||
<option value="10" selected>10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes overlay root -->
|
||||
<div id="noteOverlay" class="note-overlay" aria-hidden="true"></div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
const app = Vue.createApp({
|
||||
data() {
|
||||
return {
|
||||
ctx: window.__ctx || { isManager: false, meId: 0 },
|
||||
api: `${window.location.origin}/api/BookingsApi`,
|
||||
|
||||
users: [], rooms: [],
|
||||
userMap: new Map(), roomMap: new Map(),
|
||||
|
||||
showFilters: false,
|
||||
fltFrom: "", fltTo: "", fltUser: "",
|
||||
|
||||
activeStatus: "Pending",
|
||||
statuses: ["Pending", "Approved", "Rejected", "Cancelled"],
|
||||
|
||||
rows: [],
|
||||
isLoading: false,
|
||||
alert: { type: "", msg: "" },
|
||||
|
||||
currentPage: 1,
|
||||
pageSize: 10,
|
||||
|
||||
notesById: new Map()
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
showFilters() {
|
||||
const card = document.getElementById("filtersCard");
|
||||
if (card) card.style.display = this.showFilters ? "block" : "none";
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
queryParams() {
|
||||
const p = new URLSearchParams();
|
||||
p.set("pageSize", "500");
|
||||
|
||||
const fromIso = this.localDateToUtcIsoStart(this.fltFrom);
|
||||
const toIso = this.localDateToUtcIsoEnd(this.fltTo);
|
||||
if (fromIso) p.set("from", fromIso);
|
||||
if (toIso) p.set("to", toIso);
|
||||
|
||||
if (this.ctx.isManager && this.fltUser) p.set("userId", String(this.fltUser));
|
||||
|
||||
return p.toString();
|
||||
},
|
||||
|
||||
statusCounts() {
|
||||
const counts = { Pending: 0, Approved: 0, Rejected: 0, Cancelled: 0 };
|
||||
for (const r of this.rows) {
|
||||
const s = (r.status ?? r.Status ?? "").toString();
|
||||
if (counts[s] != null) counts[s]++;
|
||||
}
|
||||
return counts;
|
||||
},
|
||||
|
||||
filteredRows() {
|
||||
const list = this.rows.filter(r => {
|
||||
const st = (r.status ?? r.Status ?? "").toString();
|
||||
if (st !== this.activeStatus) return false;
|
||||
if (this.ctx.isManager && this.fltUser) {
|
||||
const uid = Number(r.requestedByUserId ?? r.RequestedByUserId ?? r.userId ?? 0);
|
||||
if (String(uid) !== String(this.fltUser)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return list.slice().sort((a, b) => {
|
||||
const aC = this.getTimeNum(a.createdUtc ?? a.CreatedUtc);
|
||||
const bC = this.getTimeNum(b.createdUtc ?? b.CreatedUtc);
|
||||
if (aC !== bC) return (bC ?? -Infinity) - (aC ?? -Infinity);
|
||||
|
||||
const aS = this.getTimeNum(a.startUtc ?? a.StartUtc);
|
||||
const bS = this.getTimeNum(b.startUtc ?? b.StartUtc);
|
||||
if (aS !== bS) return (bS ?? -Infinity) - (aS ?? -Infinity);
|
||||
|
||||
const aId = this.getNum(a.id ?? a.Id ?? a.bookingId ?? a.BookingId);
|
||||
const bId = this.getNum(b.id ?? b.Id ?? b.bookingId ?? b.BookingId);
|
||||
return (bId ?? -Infinity) - (aId ?? -Infinity);
|
||||
});
|
||||
},
|
||||
|
||||
totalPages() { return Math.max(1, Math.ceil(this.filteredRows.length / this.pageSize)); },
|
||||
|
||||
pagedRows() {
|
||||
const start = (this.currentPage - 1) * this.pageSize;
|
||||
const end = start + this.pageSize;
|
||||
return this.filteredRows.slice(start, end);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
// ===== tabs & alerts =====
|
||||
setStatus(s) {
|
||||
if (this.activeStatus !== s) {
|
||||
this.activeStatus = s;
|
||||
this.currentPage = 1;
|
||||
this.renderTable();
|
||||
}
|
||||
},
|
||||
setAlert(type, msg) {
|
||||
this.alert = { type, msg };
|
||||
const wrap = document.getElementById("alerts");
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = `
|
||||
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||
${this.escapeHtml(msg)}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>`;
|
||||
},
|
||||
escapeHtml(s) {
|
||||
return String(s ?? "").replace(/[&<>\"']/g, m => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
}[m]));
|
||||
},
|
||||
|
||||
// ===== time & formatting helpers =====
|
||||
getTimeNum(v) { if (!v) return null; const t = new Date(v).getTime(); return Number.isNaN(t) ? null : t; },
|
||||
getNum(v) { const n = Number(v); return Number.isNaN(n) ? null : n; },
|
||||
|
||||
/* e.g., 7/9/2025 (no leading zeros) */
|
||||
formatDateDMY(s) {
|
||||
if (!s) return "";
|
||||
const d = new Date(s); if (isNaN(d)) return this.escapeHtml(s);
|
||||
return `${d.getDate()}/${d.getMonth() + 1}/${d.getFullYear()}`;
|
||||
},
|
||||
/* e.g., 08:05 (zero-padded) */
|
||||
formatTimeHM(s) {
|
||||
if (!s) return "";
|
||||
const d = new Date(s); if (isNaN(d)) return "";
|
||||
const pad = n => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
},
|
||||
|
||||
localDateToUtcIsoStart(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
const d = new Date(dateStr + "T00:00");
|
||||
const utc = new Date(d.getTime() - d.getTimezoneOffset() * 60000);
|
||||
return utc.toISOString();
|
||||
},
|
||||
localDateToUtcIsoEnd(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
const d = new Date(dateStr + "T23:59:59.999");
|
||||
const utc = new Date(d.getTime() - d.getTimezoneOffset() * 60000);
|
||||
return utc.toISOString();
|
||||
},
|
||||
|
||||
// ===== lookups & data =====
|
||||
async loadLookups() {
|
||||
try {
|
||||
const res = await fetch(`${this.api}?lookups=1`);
|
||||
if (!res.ok) return;
|
||||
const { rooms = [], users = [] } = await res.json();
|
||||
|
||||
this.users = users; this.rooms = rooms;
|
||||
|
||||
const sel = document.getElementById("fltUser");
|
||||
if (sel) {
|
||||
const prev = sel.value ?? "";
|
||||
sel.innerHTML = '<option value="">All users</option>';
|
||||
users.forEach(u => {
|
||||
const id = Number(u.Id ?? u.id); if (!id) return;
|
||||
const name = u.UserName ?? u.userName ?? "";
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(id); opt.textContent = name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (prev && [...sel.options].some(o => o.value === prev)) { sel.value = prev; this.fltUser = prev; }
|
||||
}
|
||||
|
||||
this.userMap = new Map(users.map(u => [Number(u.Id ?? u.id), (u.UserName ?? u.userName ?? "")]));
|
||||
this.roomMap = new Map(rooms.map(r => {
|
||||
const id = Number(r.roomId ?? r.RoomId);
|
||||
const name = r.roomName ?? r.Name ?? r.RoomName ?? `Room ${id}`;
|
||||
return [id, name];
|
||||
}));
|
||||
} catch { }
|
||||
},
|
||||
|
||||
async loadList() {
|
||||
const $tbody = document.querySelector("#bookingsTable tbody");
|
||||
const cols = this.ctx.isManager ? 7 : 6;
|
||||
if ($tbody) $tbody.innerHTML = `<tr><td colspan="${cols}" class="text-center">Loading…</td></tr>`;
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${this.api}?${this.queryParams}`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
this.rows = Array.isArray(data) ? data : [];
|
||||
this.currentPage = 1;
|
||||
this.renderTable();
|
||||
} catch (err) {
|
||||
if ($tbody) $tbody.innerHTML = `<tr><td colspan="${cols}" class="text-danger text-center">Failed to load: ${this.escapeHtml(err.message)}</td></tr>`;
|
||||
this.setAlert("danger", err.message || "Failed to load.");
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ===== table render =====
|
||||
renderTable() {
|
||||
const $tbody = document.querySelector("#bookingsTable tbody");
|
||||
if (!$tbody) return;
|
||||
|
||||
const visible = this.pedRowsSafe();
|
||||
const cols = this.ctx.isManager ? 7 : 6;
|
||||
|
||||
if (!visible.length) {
|
||||
$tbody.innerHTML = `<tr><td colspan="${cols}" class="text-center">No bookings found.</td></tr>`;
|
||||
this.updatePager();
|
||||
return;
|
||||
}
|
||||
|
||||
$tbody.innerHTML = "";
|
||||
const showModeration = this.ctx.isManager && this.activeStatus === "Pending";
|
||||
this.notesById = new Map();
|
||||
|
||||
for (const b of visible) {
|
||||
const id = b.id ?? b.Id ?? b.bookingId ?? b.BookingId;
|
||||
const title = b.title ?? "";
|
||||
const note = (b.note ?? b.description ?? "").toString();
|
||||
const hasNote = note.trim().length > 0;
|
||||
if (id != null) this.notesById.set(String(id), note);
|
||||
|
||||
const start = b.startUtc ?? b.StartUtc;
|
||||
const end = b.endUtc ?? b.EndUtc;
|
||||
|
||||
const dateDisp = this.formatDateDMY(start);
|
||||
const timeStart = this.formatTimeHM(start);
|
||||
const timeEnd = this.formatTimeHM(end);
|
||||
const timeDisp = (timeStart || timeEnd) ? `${timeStart || "—"} - ${timeEnd || "—"}` : "—";
|
||||
|
||||
const ridRaw = (b.roomId ?? b.RoomId ?? null);
|
||||
const roomId = ridRaw == null ? null : Number(ridRaw);
|
||||
const roomDisp = (roomId && !Number.isNaN(roomId))
|
||||
? (this.roomMap.get(roomId) ?? `Room #${roomId}`)
|
||||
: (b.roomName ?? "(unknown)");
|
||||
|
||||
const uidRaw = (b.requestedByUserId ?? b.RequestedByUserId ?? null);
|
||||
const uid = uidRaw == null ? null : Number(uidRaw);
|
||||
const userDisp = (uid && !Number.isNaN(uid))
|
||||
? (this.userMap.get(uid) ?? `User #${uid}`)
|
||||
: (b.userName ?? "(unknown)");
|
||||
|
||||
const status = (b.status ?? b.Status ?? "").toString();
|
||||
const isCancelled = status === "Cancelled";
|
||||
const isApproved = status === "Approved";
|
||||
const isRejected = status === "Rejected";
|
||||
|
||||
const canEdit = !(isApproved || isRejected || isCancelled);
|
||||
const canCancel = !isRejected;
|
||||
|
||||
const cancelLabel = isCancelled ? "Un-cancel" : "Cancel";
|
||||
const approveDisabled = (isApproved || isCancelled) ? "disabled" : "";
|
||||
const rejectDisabled = (isRejected || isCancelled) ? "disabled" : "";
|
||||
|
||||
const managerMenu = showModeration ? `
|
||||
<li><button type="button" class="dropdown-item" data-action="approve" data-id="${id}" ${approveDisabled}>Approve</button></li>
|
||||
<li><button type="button" class="dropdown-item" data-action="reject" data-id="${id}" ${rejectDisabled}>Reject</button></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
` : "";
|
||||
|
||||
const userCellHtml = this.ctx.isManager
|
||||
? `<td class="col-user">${this.escapeHtml(userDisp)}</td>`
|
||||
: ``;
|
||||
|
||||
// Inline icon actions (no dropdown)
|
||||
const editBtn = canEdit
|
||||
? `<button type="button" class="icon-btn" title="Edit" aria-label="Edit"
|
||||
data-action="edit" data-id="${id}">
|
||||
<i class="bi bi-pencil-square text-warning fs-6"></i>
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
const approveBtn = (this.ctx.isManager && this.activeStatus === "Pending")
|
||||
? `<button type="button" class="icon-btn" title="Approve" aria-label="Approve"
|
||||
data-action="approve" data-id="${id}" ${approveDisabled}>
|
||||
<i class="bi bi-check2-circle text-success fs-6"></i>
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
const rejectBtn = (this.ctx.isManager && this.activeStatus === "Pending")
|
||||
? `<button type="button" class="icon-btn" title="Reject" aria-label="Reject"
|
||||
data-action="reject" data-id="${id}" ${rejectDisabled}>
|
||||
<i class="bi bi-x-circle text-danger fs-6"></i>
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
const cancelIcon = isCancelled ? "bi-arrow-counterclockwise" : "bi-slash-circle";
|
||||
const cancelTitle = isCancelled ? "Un-cancel" : "Cancel";
|
||||
const cancelClass = isCancelled ? "text-secondary" : "text-danger";
|
||||
const cancelBtn = canCancel
|
||||
? `<button type="button" class="icon-btn" title="${cancelTitle}" aria-label="${cancelTitle}"
|
||||
data-action="toggle-cancel" data-id="${id}" data-status="${this.escapeHtml(status)}">
|
||||
<i class="bi ${cancelIcon} ${cancelClass} fs-6"></i>
|
||||
</button>`
|
||||
: '';
|
||||
|
||||
const actionsHtml = `
|
||||
${editBtn}
|
||||
${approveBtn}
|
||||
${rejectBtn}
|
||||
${cancelBtn}
|
||||
`;
|
||||
|
||||
|
||||
const noteCell = hasNote
|
||||
? `<button type="button" class="btn btn-sm btn-outline-primary" data-action="show-note" data-id="${id}">Notes</button>`
|
||||
: `<span class="text-muted">—</span>`;
|
||||
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td class="td-title">
|
||||
<div class="line-clamp-2" title="${this.escapeHtml(title)}">${this.escapeHtml(title)}</div>
|
||||
</td>
|
||||
<td class="td-notes">
|
||||
${noteCell}
|
||||
</td>
|
||||
<td>${this.escapeHtml(roomDisp)}</td>
|
||||
<td>${this.escapeHtml(dateDisp)}</td>
|
||||
<td>${this.escapeHtml(timeDisp)}</td>
|
||||
${userCellHtml}
|
||||
<td class="actions-col">
|
||||
${actionsHtml.trim() ? actionsHtml : `<span class="text-muted">—</span>`}
|
||||
</td>`;
|
||||
$tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
this.updatePager();
|
||||
},
|
||||
|
||||
pedRowsSafe() { try { return this.pagedRows; } catch { return []; } },
|
||||
|
||||
// ===== server actions =====
|
||||
async postAction(action, id, opts = {}) {
|
||||
const url = new URL(`${this.api}`);
|
||||
url.searchParams.set("action", action);
|
||||
url.searchParams.set("id", id);
|
||||
if (opts.undo) url.searchParams.set("undo", "1");
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(text || `Failed (${res.status})`);
|
||||
try { return JSON.parse(text); } catch { return {}; }
|
||||
},
|
||||
async handleApprove(id, btn) {
|
||||
btn && (btn.disabled = true);
|
||||
try { await this.postAction("approve", id); this.setAlert("success", "Booking approved."); await this.loadList(); }
|
||||
catch (e) { this.setAlert("danger", e.message || "Approve failed."); }
|
||||
finally { btn && (btn.disabled = false); }
|
||||
},
|
||||
async handleReject(id, btn) {
|
||||
btn && (btn.disabled = true);
|
||||
try { await this.postAction("reject", id); this.setAlert("success", "Booking rejected."); await this.loadList(); }
|
||||
catch (e) { this.setAlert("danger", e.message || "Reject failed."); }
|
||||
finally { btn && (btn.disabled = false); }
|
||||
},
|
||||
async handleToggleCancel(id, status, btn) {
|
||||
btn && (btn.disabled = true);
|
||||
const undo = String(status || "").toLowerCase() === "cancelled";
|
||||
try { await this.postAction("cancel", id, { undo }); await this.loadList(); }
|
||||
catch (e) { this.setAlert("danger", e.message || "Cancel action failed."); }
|
||||
finally { btn && (btn.disabled = false); }
|
||||
},
|
||||
|
||||
// ===== notes popup =====
|
||||
showNoteCard(id) {
|
||||
const title = (this.filteredRows.find(r => String(r.id ?? r.Id ?? r.bookingId ?? r.BookingId) === String(id))?.title ?? "") + "";
|
||||
const note = this.notesById.get(String(id)) ?? "";
|
||||
|
||||
const overlay = document.getElementById("noteOverlay");
|
||||
if (!overlay) return;
|
||||
|
||||
const safeTitle = this.escapeHtml(title || "Notes");
|
||||
const safeNote = this.escapeHtml(note || "(No notes)");
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="note-card" role="dialog" aria-modal="true">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<h6 class="mb-0">${safeTitle}</h6>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-note-close>Close</button>
|
||||
</div>
|
||||
<div class="note-body">${safeNote.replace(/\\n/g, '<br/>')}</div>
|
||||
</div>
|
||||
`;
|
||||
overlay.style.display = "flex";
|
||||
|
||||
const closeAll = () => { overlay.style.display = "none"; overlay.innerHTML = ""; };
|
||||
overlay.onclick = (e) => { if (e.target === overlay) closeAll(); };
|
||||
overlay.querySelector("[data-note-close]")?.addEventListener("click", closeAll);
|
||||
document.addEventListener("keydown", function esc(e) { if (e.key === "Escape") { closeAll(); document.removeEventListener("keydown", esc); } });
|
||||
},
|
||||
|
||||
// ===== filters & pager =====
|
||||
bindFilterInputs() {
|
||||
const bind = (id, setter) => {
|
||||
const el = document.getElementById(id); if (!el) return;
|
||||
const h = e => { this[setter] = e.target.value ?? ""; };
|
||||
el.addEventListener("change", h); el.addEventListener("input", h);
|
||||
};
|
||||
bind("fltFrom", "fltFrom"); bind("fltTo", "fltTo"); bind("fltUser", "fltUser");
|
||||
},
|
||||
syncFiltersFromDom() {
|
||||
const get = id => document.getElementById(id)?.value ?? "";
|
||||
this.fltFrom = get("fltFrom"); this.fltTo = get("fltTo"); this.fltUser = get("fltUser");
|
||||
},
|
||||
applyFilters() { this.syncFiltersFromDom(); this.currentPage = 1; this.loadList(); },
|
||||
clearFilters() {
|
||||
this.fltFrom = this.fltTo = this.fltUser = "";
|
||||
["fltFrom", "fltTo", "fltUser"].forEach(id => { const el = document.getElementById(id); if (el) el.value = ""; });
|
||||
this.currentPage = 1; this.loadList();
|
||||
},
|
||||
toggleFilters() { this.showFilters = !this.showFilters; },
|
||||
|
||||
updatePager() {
|
||||
const total = this.filteredRows.length;
|
||||
const totalPages = this.totalPages;
|
||||
const pageInfo = document.getElementById("pageInfo");
|
||||
const rangeInfo = document.getElementById("rangeInfo");
|
||||
const btnPrev = document.getElementById("btnPrev");
|
||||
const btnNext = document.getElementById("btnNext");
|
||||
|
||||
if (pageInfo) pageInfo.textContent = `Page ${this.currentPage} of ${totalPages}`;
|
||||
const startIdx = total ? (this.currentPage - 1) * this.pageSize + 1 : 0;
|
||||
const endIdx = Math.min(this.currentPage * this.pageSize, total);
|
||||
if (rangeInfo) rangeInfo.textContent = `(Showing ${startIdx}–${endIdx} of ${total})`;
|
||||
|
||||
if (btnPrev) btnPrev.disabled = this.currentPage <= 1;
|
||||
if (btnNext) btnNext.disabled = this.currentPage >= totalPages;
|
||||
},
|
||||
prevPage() { if (this.currentPage > 1) { this.currentPage--; this.renderTable(); this.updatePager(); } },
|
||||
nextPage() { if (this.currentPage < this.totalPages) { this.currentPage++; this.renderTable(); this.updatePager(); } },
|
||||
setPageSize(n) {
|
||||
const newSize = Number(n) || 10;
|
||||
if (newSize !== this.pageSize) {
|
||||
this.pageSize = newSize; this.currentPage = 1; this.renderTable(); this.updatePager();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.fltFrom = document.getElementById("fltFrom")?.value ?? "";
|
||||
this.fltTo = document.getElementById("fltTo")?.value ?? "";
|
||||
this.fltUser = document.getElementById("fltUser")?.value ?? "";
|
||||
|
||||
this.bindFilterInputs();
|
||||
|
||||
document.getElementById("btnToggleFilters")?.addEventListener("click", this.toggleFilters);
|
||||
document.getElementById("btnApply")?.addEventListener("click", this.applyFilters);
|
||||
document.getElementById("btnClear")?.addEventListener("click", this.clearFilters);
|
||||
document.getElementById("btnPrev")?.addEventListener("click", this.prevPage);
|
||||
document.getElementById("btnNext")?.addEventListener("click", this.nextPage);
|
||||
const selPageSize = document.getElementById("selPageSize");
|
||||
if (selPageSize) {
|
||||
selPageSize.value = String(this.pageSize);
|
||||
selPageSize.addEventListener("change", e => this.setPageSize(e.target.value));
|
||||
}
|
||||
|
||||
const table = document.getElementById("bookingsTable");
|
||||
if (table) {
|
||||
table.addEventListener("click", (e) => {
|
||||
const el = e.target instanceof Element ? e.target : null; if (!el) return;
|
||||
const btn = el.closest("[data-action]"); if (!btn) return;
|
||||
|
||||
const action = btn.getAttribute("data-action");
|
||||
const id = btn.getAttribute("data-id");
|
||||
if (!action || !id) return;
|
||||
|
||||
if (action === "approve") return this.handleApprove(id, btn);
|
||||
if (action === "reject") return this.handleReject(id, btn);
|
||||
if (action === "toggle-cancel") {
|
||||
const status = btn.getAttribute("data-status") || "";
|
||||
return this.handleToggleCancel(id, status, btn);
|
||||
}
|
||||
if (action === "show-note") { return this.showNoteCard(id); }
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const el = e.target instanceof Element ? e.target : null; if (!el) return;
|
||||
const btn = el.closest("[data-action]"); if (!btn) return;
|
||||
|
||||
const action = btn.getAttribute("data-action");
|
||||
const id = btn.getAttribute("data-id");
|
||||
if (!action || !id) return;
|
||||
|
||||
if (action === "approve") return this.handleApprove(id, btn);
|
||||
if (action === "reject") return this.handleReject(id, btn);
|
||||
if (action === "toggle-cancel") {
|
||||
const status = btn.getAttribute("data-status") || "";
|
||||
return this.handleToggleCancel(id, status, btn);
|
||||
}
|
||||
if (action === "edit") {
|
||||
const url = '@Url.Action("Create", "Bookings", new { area = "Bookings" })' + `?id=${encodeURIComponent(id)}`;
|
||||
return window.location.assign(url);
|
||||
}
|
||||
|
||||
if (action === "show-note") { return this.showNoteCard(id); }
|
||||
});
|
||||
|
||||
this.loadLookups().then(() => this.loadList());
|
||||
}
|
||||
});
|
||||
|
||||
app.mount("#app");
|
||||
</script>
|
||||
}
|
||||
227
Areas/Bookings/Views/Bookings/Managers.cshtml
Normal file
227
Areas/Bookings/Views/Bookings/Managers.cshtml
Normal file
@ -0,0 +1,227 @@
|
||||
@{
|
||||
ViewData["Title"] = "Booking Managers";
|
||||
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||
}
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
|
||||
<style>
|
||||
.card {
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,.06);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: #f9fbff;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
border-top-left-radius: 14px;
|
||||
border-top-right-radius: 14px;
|
||||
}
|
||||
|
||||
.it-list {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
border: 1px solid #eef2f7;
|
||||
border-radius: 10px;
|
||||
padding: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.it-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.it-item:hover {
|
||||
background: #f7f9fc;
|
||||
}
|
||||
|
||||
.it-name {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.it-selected {
|
||||
min-height: 48px;
|
||||
border: 1px dashed #dbe3ef;
|
||||
background: #fbfdff;
|
||||
border-radius: 10px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .4rem;
|
||||
padding: .28rem .5rem;
|
||||
margin: 4px;
|
||||
border-radius: 999px;
|
||||
background: #eef2ff;
|
||||
color: #3949ab;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chip-x {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #6b7280;
|
||||
line-height: 1;
|
||||
font-size: 16px;
|
||||
padding: 0 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip-x:hover {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="mgrApp" style="max-width:1000px; margin:auto; font-size:13px;">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="m-0">Booking Managers</h5>
|
||||
<div>
|
||||
<button class="btn btn-outline-secondary btn-sm" @@click="loadAll" :disabled="busy">Refresh</button>
|
||||
<button class="btn btn-primary btn-sm ms-2" @@click="save" :disabled="saving || busy">
|
||||
{{ saving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
|
||||
<div class="text-muted mb-3">
|
||||
<small>Select users who should act as <strong>Managers</strong> for the Room Booking module (approve/reject, manage rooms, etc.).</small>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 align-items-start">
|
||||
<!-- LEFT: Search + Available users -->
|
||||
<div class="col-md-7">
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control" placeholder="Search users by name…" v-model.trim="q">
|
||||
</div>
|
||||
|
||||
<div class="it-list">
|
||||
<label v-for="u in filteredUsers" :key="'u-'+u.id" class="it-item">
|
||||
<input type="checkbox" :value="u.id" v-model="managerIds" />
|
||||
<span class="it-name">{{ u.name }}</span>
|
||||
<small class="text-muted ms-1">({{ u.email }})</small>
|
||||
</label>
|
||||
<div v-if="!filteredUsers.length" class="text-muted small p-2">No users match your search.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: Selected chips -->
|
||||
<div class="col-md-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<strong>Selected ({{ selectedUsers.length }})</strong>
|
||||
<button class="btn btn-link btn-sm text-decoration-none"
|
||||
@@click="managerIds = []"
|
||||
:disabled="!selectedUsers.length">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="it-selected">
|
||||
<span v-for="u in selectedUsers" :key="'sel-'+u.id" class="chip">
|
||||
{{ u.name }}
|
||||
<button class="chip-x" @@click="remove(u.id)" aria-label="Remove">×</button>
|
||||
</span>
|
||||
<div v-if="!selectedUsers.length" class="text-muted small">Nobody selected yet.</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 form-text">
|
||||
Managers can: approve/reject bookings, create/update rooms, cancel/un-cancel any booking, etc.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const mgrApp = Vue.createApp({
|
||||
data() {
|
||||
return {
|
||||
busy: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
q: '',
|
||||
users: [], // {id, name, email}
|
||||
managerIds: [] // [int]
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredUsers() {
|
||||
const k = (this.q || '').toLowerCase();
|
||||
return !k ? this.users : this.users.filter(u =>
|
||||
(u.name || '').toLowerCase().includes(k) || (u.email || '').toLowerCase().includes(k)
|
||||
);
|
||||
},
|
||||
selectedUsers() {
|
||||
const set = new Set(this.managerIds);
|
||||
return this.users.filter(u => set.has(u.id));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadUsers() {
|
||||
const r = await fetch('/api/BookingsApi/users');
|
||||
if (!r.ok) throw new Error('Failed to load users');
|
||||
this.users = await r.json();
|
||||
},
|
||||
async loadManagers() {
|
||||
const r = await fetch('/api/BookingsApi/managers');
|
||||
if (!r.ok) throw new Error('Failed to load managers');
|
||||
this.managerIds = await r.json(); // array<int>
|
||||
},
|
||||
async loadAll() {
|
||||
try {
|
||||
this.busy = true; this.error = null;
|
||||
await Promise.all([this.loadUsers(), this.loadManagers()]);
|
||||
} catch (e) {
|
||||
this.error = e.message || 'Load failed.';
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
remove(id) {
|
||||
this.managerIds = this.managerIds.filter(x => x !== id);
|
||||
},
|
||||
async save() {
|
||||
try {
|
||||
this.saving = true; this.error = null;
|
||||
const r = await fetch('/api/BookingsApi/managers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userIds: this.managerIds })
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(() => ({}));
|
||||
throw new Error(j.message || `Save failed (${r.status})`);
|
||||
}
|
||||
alert('Managers updated.');
|
||||
} catch (e) {
|
||||
this.error = e.message || 'Save failed.';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadAll();
|
||||
}
|
||||
});
|
||||
mgrApp.mount('#mgrApp');
|
||||
</script>
|
||||
268
Areas/Bookings/Views/Bookings/Room.cshtml
Normal file
268
Areas/Bookings/Views/Bookings/Room.cshtml
Normal file
@ -0,0 +1,268 @@
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@{
|
||||
ViewData["Title"] = "Rooms";
|
||||
Layout = "_Layout";
|
||||
}
|
||||
<style>
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,.1);
|
||||
padding: 25px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.table th, .table td {
|
||||
vertical-align: middle;
|
||||
text-align: center;
|
||||
font-size: 14px
|
||||
}
|
||||
|
||||
.header-blue {
|
||||
background: #cfe2f3 !important
|
||||
}
|
||||
|
||||
.header-green {
|
||||
background: #d9ead3 !important
|
||||
}
|
||||
|
||||
.header-orange {
|
||||
background: #fce5cd !important
|
||||
}
|
||||
|
||||
.btn, input.form-control, select.form-control {
|
||||
border-radius: 10px;
|
||||
font-size: 13px
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
overflow-x: auto
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="mb-3"></h2>
|
||||
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#roomModal" onclick="openCreate()">New Room</button>
|
||||
</div>
|
||||
|
||||
<div id="alerts"></div>
|
||||
|
||||
<div class="table-responsive table-container">
|
||||
<table class="table table-bordered table-striped align-middle" id="roomsTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="header-blue">Name</th>
|
||||
<th class="header-blue">Location</th>
|
||||
<th class="header-green">Capacity</th>
|
||||
<th class="header-green">Active</th>
|
||||
<th class="header-orange">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr><td colspan="5" class="text-center">Loading…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div class="modal fade" id="roomModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content" style="border-radius:14px;">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="roomModalTitle">New Room</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="RoomId" />
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Room Name</label>
|
||||
<input id="RoomName" class="form-control" maxlength="120" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Location Code</label>
|
||||
<input id="LocationCode" class="form-control" maxlength="40" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Capacity</label>
|
||||
<input id="Capacity" class="form-control" type="number" min="0" />
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="IsActive" checked />
|
||||
<label class="form-check-label" for="IsActive">Active</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button class="btn btn-success" id="saveBtn" onclick="saveRoom()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
(() => {
|
||||
// --- Constants / refs ---
|
||||
const api = `${window.location.origin}/api/BookingsApi`;
|
||||
const tbody = document.querySelector("#roomsTable tbody");
|
||||
|
||||
// ---------- UI helpers ----------
|
||||
function showAlert(type, msg) {
|
||||
document.getElementById("alerts").innerHTML = `
|
||||
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
|
||||
${msg}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? "").replace(/[&<>\"']/g, m =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
}[m])
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Data load ----------
|
||||
async function loadRooms() {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="text-center">Loading…</td></tr>`;
|
||||
try {
|
||||
const res = await fetch(`${api}?scope=rooms`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
const data = await res.json();
|
||||
if (!Array.isArray(data) || data.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="text-center">No rooms yet.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = "";
|
||||
for (const r of data) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${escapeHtml(r.roomName)}</td>
|
||||
<td>${escapeHtml(r.locationCode ?? "")}</td>
|
||||
<td>${r.capacity ?? ""}</td>
|
||||
<td>
|
||||
${r.isActive
|
||||
? '<span class="badge bg-success">Yes</span>'
|
||||
: '<span class="badge bg-secondary">No</span>'
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-warning me-2"
|
||||
onclick='openEdit(${r.roomId},"${escapeHtml(
|
||||
r.roomName
|
||||
)}","${escapeHtml(r.locationCode ?? "")}",${r.capacity ?? "null"
|
||||
},${r.isActive})'>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
onclick="deleteRoom(${r.roomId})">
|
||||
Delete
|
||||
</button>
|
||||
</td>`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="text-danger text-center">Failed: ${escapeHtml(
|
||||
err.message
|
||||
)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Modal helpers ----------
|
||||
window.openCreate = function () {
|
||||
document.getElementById("roomModalTitle").textContent = "New Room";
|
||||
document.getElementById("RoomId").value = "";
|
||||
document.getElementById("RoomName").value = "";
|
||||
document.getElementById("LocationCode").value = "";
|
||||
document.getElementById("Capacity").value = "";
|
||||
document.getElementById("IsActive").checked = true;
|
||||
};
|
||||
|
||||
window.openEdit = function (id, name, loc, cap, active) {
|
||||
document.getElementById("roomModalTitle").textContent = "Edit Room";
|
||||
document.getElementById("RoomId").value = id;
|
||||
document.getElementById("RoomName").value = name;
|
||||
document.getElementById("LocationCode").value = loc === "null" ? "" : loc;
|
||||
document.getElementById("Capacity").value = cap === null ? "" : cap;
|
||||
document.getElementById("IsActive").checked = !!active;
|
||||
|
||||
const modal = new bootstrap.Modal(document.getElementById("roomModal"));
|
||||
modal.show();
|
||||
};
|
||||
|
||||
// ---------- Save / Delete ----------
|
||||
window.saveRoom = async function () {
|
||||
const id = document.getElementById("RoomId").value;
|
||||
const roomName = document.getElementById("RoomName").value.trim();
|
||||
const locationCode = document.getElementById("LocationCode").value.trim();
|
||||
const capacity = document.getElementById("Capacity").value
|
||||
? Number(document.getElementById("Capacity").value)
|
||||
: null;
|
||||
const isActive = document.getElementById("IsActive").checked;
|
||||
|
||||
if (!roomName) {
|
||||
showAlert("danger", "Room Name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let res;
|
||||
const payload = {
|
||||
RoomName: roomName,
|
||||
LocationCode: locationCode || null,
|
||||
Capacity: capacity,
|
||||
IsActive: isActive
|
||||
};
|
||||
|
||||
if (id) {
|
||||
res = await fetch(`${api}?scope=rooms&id=${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
} else {
|
||||
res = await fetch(`${api}?scope=rooms`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
showAlert("success", "Room saved.");
|
||||
bootstrap.Modal.getInstance(document.getElementById("roomModal"))?.hide();
|
||||
await loadRooms();
|
||||
} catch (err) {
|
||||
showAlert("danger", "Save failed: " + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
window.deleteRoom = async function (id) {
|
||||
if (!confirm("Delete this room?")) return;
|
||||
try {
|
||||
const res = await fetch(`${api}?scope=rooms&id=${encodeURIComponent(id)}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
|
||||
showAlert("success", "Room deleted.");
|
||||
await loadRooms();
|
||||
} catch (err) {
|
||||
showAlert("danger", "Delete failed: " + (err?.message || ""));
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- Boot ----------
|
||||
document.addEventListener("DOMContentLoaded", loadRooms);
|
||||
})();
|
||||
</script>
|
||||
|
||||
}
|
||||
|
||||
79
Areas/IT/Controllers/ApprovalDashboardController.cs
Normal file
79
Areas/IT/Controllers/ApprovalDashboardController.cs
Normal file
@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PSTW_CentralSystem.DBContext;
|
||||
using PSTW_CentralSystem.Models;
|
||||
|
||||
namespace PSTW_CentralSystem.Areas.IT.Controllers
|
||||
{
|
||||
[Area("IT")]
|
||||
[Authorize]
|
||||
public class ApprovalDashboardController : Controller
|
||||
{
|
||||
private readonly CentralSystemContext _db;
|
||||
private readonly UserManager<UserModel> _userManager;
|
||||
|
||||
public ApprovalDashboardController(CentralSystemContext db, UserManager<UserModel> userManager)
|
||||
{
|
||||
_db = db;
|
||||
_userManager = userManager;
|
||||
}
|
||||
|
||||
// ===== helpers =====
|
||||
private int GetCurrentUserId() => int.Parse(_userManager.GetUserId(User)!);
|
||||
|
||||
private async Task<bool> IsItTeamAsync(int userId) =>
|
||||
await _db.ItTeamMembers.AnyAsync(t => t.UserId == userId);
|
||||
|
||||
private async Task<bool> IsApproverInAnyFlowAsync(int userId) =>
|
||||
await _db.ItApprovalFlows.AnyAsync(f =>
|
||||
f.HodUserId == userId ||
|
||||
f.GroupItHodUserId == userId ||
|
||||
f.FinHodUserId == userId ||
|
||||
f.MgmtUserId == userId);
|
||||
|
||||
private async Task<bool> IsRequestFormManagerAsync(int userId) =>
|
||||
await _db.RequestFormManagers.AnyAsync(m => m.UserId == userId);
|
||||
|
||||
// ===== routes =====
|
||||
|
||||
// Approval is only available for approvers and IT team members
|
||||
public async Task<IActionResult> Approval()
|
||||
{
|
||||
var uid = GetCurrentUserId();
|
||||
|
||||
var isAllowed = await IsItTeamAsync(uid) || await IsApproverInAnyFlowAsync(uid);
|
||||
if (!isAllowed) return Forbid(); // or: return View("AccessDenied");
|
||||
|
||||
return View(); // ~/Areas/IT/Views/ApprovalDashboard/Approval.cshtml
|
||||
}
|
||||
|
||||
// Assignings (Admin) is only available for Request Form Managers
|
||||
public async Task<IActionResult> Admin()
|
||||
{
|
||||
var uid = GetCurrentUserId();
|
||||
|
||||
var isManager = await IsRequestFormManagerAsync(uid);
|
||||
if (!isManager) return Forbid(); // or: return View("AccessDenied");
|
||||
|
||||
return View(); // ~/Areas/IT/Views/ApprovalDashboard/Admin.cshtml
|
||||
}
|
||||
|
||||
// Open to any authenticated user
|
||||
public IActionResult Create() => View(); // ~/Areas/IT/Views/ApprovalDashboard/Create.cshtml
|
||||
public IActionResult MyRequests() => View(); // ~/Areas/IT/Views/ApprovalDashboard/MyRequests.cshtml
|
||||
|
||||
// Use the same gate as Approval (reviewing a specific request)
|
||||
public IActionResult RequestReview(int statusId)
|
||||
{
|
||||
ViewBag.StatusId = statusId;
|
||||
return View(); // ~/Areas/IT/Views/ApprovalDashboard/RequestReview.cshtml
|
||||
}
|
||||
|
||||
// Leave these open unless you want extra guards
|
||||
public IActionResult SectionB() => View();
|
||||
public IActionResult Edit() => View();
|
||||
public IActionResult SectionBEdit() => View();
|
||||
}
|
||||
}
|
||||
21
Areas/IT/Models/ItApprovalFlow.cs
Normal file
21
Areas/IT/Models/ItApprovalFlow.cs
Normal file
@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PSTW_CentralSystem.Areas.IT.Models
|
||||
{
|
||||
[Table("it_approval_flows")]
|
||||
public class ItApprovalFlow
|
||||
{
|
||||
[Key]
|
||||
public int ItApprovalFlowId { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string FlowName { get; set; }
|
||||
|
||||
// approvers
|
||||
public int HodUserId { get; set; }
|
||||
public int GroupItHodUserId { get; set; }
|
||||
public int FinHodUserId { get; set; }
|
||||
public int MgmtUserId { get; set; }
|
||||
}
|
||||
}
|
||||
56
Areas/IT/Models/ItRequest.cs
Normal file
56
Areas/IT/Models/ItRequest.cs
Normal file
@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PSTW_CentralSystem.Areas.IT.Models
|
||||
{
|
||||
[Table("it_requests")]
|
||||
public class ItRequest
|
||||
{
|
||||
[Key]
|
||||
public int ItRequestId { get; set; }
|
||||
|
||||
public int UserId { get; set; } // FK -> aspnetusers.Id
|
||||
|
||||
// snapshot fields (taken at submission time)
|
||||
[Required]
|
||||
[MaxLength(200)]
|
||||
public string StaffName { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? CompanyName { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? DepartmentName { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? Designation { get; set; }
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? Location { get; set; }
|
||||
|
||||
[MaxLength(50)]
|
||||
public string? EmploymentStatus { get; set; } // Permanent / Contract / Temp / New Staff
|
||||
|
||||
public DateTime? ContractEndDate { get; set; }
|
||||
|
||||
public DateTime RequiredDate { get; set; }
|
||||
|
||||
[MaxLength(20)]
|
||||
public string? PhoneExt { get; set; }
|
||||
|
||||
public DateTime SubmitDate { get; set; }
|
||||
|
||||
// navigation
|
||||
public ICollection<ItRequestHardware> Hardware { get; set; } = new List<ItRequestHardware>();
|
||||
public ICollection<ItRequestEmail> Emails { get; set; } = new List<ItRequestEmail>();
|
||||
public ICollection<ItRequestOsRequirement> OsRequirements { get; set; } = new List<ItRequestOsRequirement>();
|
||||
public ICollection<ItRequestSoftware> Software { get; set; } = new List<ItRequestSoftware>();
|
||||
public ICollection<ItRequestSharedPermission> SharedPermissions { get; set; } = new List<ItRequestSharedPermission>();
|
||||
|
||||
public DateTime? FirstSubmittedAt { get; set; } // when the request was first created
|
||||
public DateTime? EditableUntil { get; set; } // FirstSubmittedAt + window
|
||||
public bool IsLockedForEdit { get; set; }
|
||||
}
|
||||
}
|
||||
36
Areas/IT/Models/ItRequestAssetInfo.cs
Normal file
36
Areas/IT/Models/ItRequestAssetInfo.cs
Normal file
@ -0,0 +1,36 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PSTW_CentralSystem.Areas.IT.Models
|
||||
{
|
||||
[Table("it_request_asset_info")]
|
||||
|
||||
public class ItRequestAssetInfo
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ItRequestId { get; set; }
|
||||
|
||||
public string? AssetNo { get; set; }
|
||||
public string? MachineId { get; set; }
|
||||
public string? IpAddress { get; set; }
|
||||
public string? WiredMac { get; set; }
|
||||
public string? WifiMac { get; set; }
|
||||
public string? DialupAcc { get; set; }
|
||||
public string? Remarks { get; set; }
|
||||
|
||||
public DateTime? LastEditedAt { get; set; }
|
||||
public int? LastEditedByUserId { get; set; }
|
||||
public string? LastEditedByName { get; set; }
|
||||
|
||||
public bool RequestorAccepted { get; set; }
|
||||
public DateTime? RequestorAcceptedAt { get; set; }
|
||||
|
||||
public bool ItAccepted { get; set; }
|
||||
public DateTime? ItAcceptedAt { get; set; }
|
||||
public int? ItAcceptedByUserId { get; set; }
|
||||
public string? ItAcceptedByName { get; set; }
|
||||
|
||||
public bool SectionBSent { get; set; } // default false
|
||||
public DateTime? SectionBSentAt { get; set; }
|
||||
}
|
||||
}
|
||||
24
Areas/IT/Models/ItRequestEmail.cs
Normal file
24
Areas/IT/Models/ItRequestEmail.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PSTW_CentralSystem.Areas.IT.Models
|
||||
{
|
||||
[Table("it_request_emails")]
|
||||
public class ItRequestEmail
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
public int ItRequestId { get; set; }
|
||||
[ForeignKey("ItRequestId")]
|
||||
public ItRequest? Request { get; set; }
|
||||
|
||||
[MaxLength(50)]
|
||||
public string? Purpose { get; set; } // New / Replacement / Additional
|
||||
|
||||
[MaxLength(200)]
|
||||
public string? ProposedAddress { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
}
|
||||
26
Areas/IT/Models/ItRequestHardware.cs
Normal file
26
Areas/IT/Models/ItRequestHardware.cs
Normal file
@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PSTW_CentralSystem.Areas.IT.Models
|
||||
{
|
||||
[Table("it_request_hardware")]
|
||||
public class ItRequestHardware
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
public int ItRequestId { get; set; }
|
||||
[ForeignKey("ItRequestId")]
|
||||
public ItRequest? Request { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string Category { get; set; } = ""; // Notebook, Desktop, etc.
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? Purpose { get; set; } // New / Replacement / Additional
|
||||
|
||||
public string? Justification { get; set; }
|
||||
|
||||
public string? OtherDescription { get; set; }
|
||||
}
|
||||
}
|
||||
18
Areas/IT/Models/ItRequestOsRequirement.cs
Normal file
18
Areas/IT/Models/ItRequestOsRequirement.cs
Normal file
@ -0,0 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PSTW_CentralSystem.Areas.IT.Models
|
||||
{
|
||||
[Table("it_request_osreqs")]
|
||||
public class ItRequestOsRequirement
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
public int ItRequestId { get; set; }
|
||||
[ForeignKey("ItRequestId")]
|
||||
public ItRequest? Request { get; set; }
|
||||
|
||||
public string? RequirementText { get; set; }
|
||||
}
|
||||
}
|
||||
24
Areas/IT/Models/ItRequestSharedPermission.cs
Normal file
24
Areas/IT/Models/ItRequestSharedPermission.cs
Normal file
@ -0,0 +1,24 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PSTW_CentralSystem.Areas.IT.Models
|
||||
{
|
||||
[Table("it_request_sharedperms")]
|
||||
public class ItRequestSharedPermission
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
public int ItRequestId { get; set; }
|
||||
[ForeignKey("ItRequestId")]
|
||||
public ItRequest? Request { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? ShareName { get; set; }
|
||||
|
||||
public bool CanRead { get; set; }
|
||||
public bool CanWrite { get; set; }
|
||||
public bool CanDelete { get; set; }
|
||||
public bool CanRemove { get; set; }
|
||||
}
|
||||
}
|
||||
26
Areas/IT/Models/ItRequestSoftware.cs
Normal file
26
Areas/IT/Models/ItRequestSoftware.cs
Normal file
@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PSTW_CentralSystem.Areas.IT.Models
|
||||
{
|
||||
[Table("it_request_software")]
|
||||
public class ItRequestSoftware
|
||||
{
|
||||
[Key]
|
||||
public int Id { get; set; }
|
||||
|
||||
public int ItRequestId { get; set; }
|
||||
[ForeignKey("ItRequestId")]
|
||||
public ItRequest? Request { get; set; }
|
||||
|
||||
[MaxLength(50)]
|
||||
public string Bucket { get; set; } = ""; // General, Utility, Custom
|
||||
|
||||
[MaxLength(200)]
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
public string? OtherName { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
}
|
||||
37
Areas/IT/Models/ItRequestStatus.cs
Normal file
37
Areas/IT/Models/ItRequestStatus.cs
Normal file
@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PSTW_CentralSystem.Areas.IT.Models
|
||||
{
|
||||
[Table("it_request_status")]
|
||||
public class ItRequestStatus
|
||||
{
|
||||
[Key]
|
||||
public int StatusId { get; set; }
|
||||
|
||||
public int ItRequestId { get; set; }
|
||||
[ForeignKey("ItRequestId")]
|
||||
public ItRequest? Request { get; set; }
|
||||
|
||||
public int ItApprovalFlowId { get; set; }
|
||||
[ForeignKey("ItApprovalFlowId")]
|
||||
public ItApprovalFlow? Flow { get; set; }
|
||||
|
||||
// per-stage statuses
|
||||
[MaxLength(20)] public string? HodStatus { get; set; }
|
||||
[MaxLength(20)] public string? GitHodStatus { get; set; }
|
||||
[MaxLength(20)] public string? FinHodStatus { get; set; }
|
||||
[MaxLength(20)] public string? MgmtStatus { get; set; }
|
||||
|
||||
public DateTime? HodSubmitDate { get; set; }
|
||||
public DateTime? GitHodSubmitDate { get; set; }
|
||||
public DateTime? FinHodSubmitDate { get; set; }
|
||||
public DateTime? MgmtSubmitDate { get; set; }
|
||||
|
||||
|
||||
|
||||
[MaxLength(20)]
|
||||
public string? OverallStatus { get; set; } // Pending / Approved / Rejected
|
||||
}
|
||||
}
|
||||
12
Areas/IT/Models/ItTeamMember.cs
Normal file
12
Areas/IT/Models/ItTeamMember.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PSTW_CentralSystem.Areas.IT.Models
|
||||
{
|
||||
[Table("it_team_members")]
|
||||
public class ItTeamMember
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int UserId { get; set; }
|
||||
}
|
||||
}
|
||||
11
Areas/IT/Models/RequestFormManager.cs
Normal file
11
Areas/IT/Models/RequestFormManager.cs
Normal file
@ -0,0 +1,11 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PSTW_CentralSystem.Areas.IT.Models
|
||||
{
|
||||
[Table("request_form_managers")]
|
||||
public class RequestFormManager
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int UserId { get; set; }
|
||||
}
|
||||
}
|
||||
730
Areas/IT/Printing/ItRequestPdfService.cs
Normal file
730
Areas/IT/Printing/ItRequestPdfService.cs
Normal file
@ -0,0 +1,730 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using QuestPDF.Drawing;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace PSTW_CentralSystem.Areas.IT.Printing
|
||||
{
|
||||
public class ItRequestPdfService
|
||||
{
|
||||
public byte[] Generate(ItRequestReportModel m)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
|
||||
return Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.A4);
|
||||
page.Margin(14);
|
||||
page.DefaultTextStyle(x => x.FontSize(7));
|
||||
|
||||
page.Content().Column(col =>
|
||||
{
|
||||
col.Item().Element(e => HeaderStrip(e, m));
|
||||
|
||||
|
||||
col.Item().PaddingTop(4).Text(
|
||||
"This form is digitally generated. Submit to http://support.transwater.com.my");
|
||||
|
||||
// ===== SECTION A =====
|
||||
|
||||
col.Item().PaddingTop(4).Text("Section A").Bold().FontSize(7);
|
||||
col.Item().Element(e => SectionA_IdentityEmployment(e, m));
|
||||
|
||||
|
||||
|
||||
// two-column layout (left = Hardware+OS+Software, right = Email+Internet+Shared+Copier)
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(1); // LEFT
|
||||
cols.ConstantColumn(8); // gutter
|
||||
cols.RelativeColumn(1); // RIGHT
|
||||
});
|
||||
|
||||
// left cell (entire left stack)
|
||||
t.Cell()
|
||||
.Element(x => x.MinHeight(380)) // optional: set a floor so both look balanced
|
||||
.Element(e => SectionA_HardwareOsSoftware(e, m));
|
||||
|
||||
// gutter cell
|
||||
t.Cell().Text("");
|
||||
|
||||
// right cell (entire right stack)
|
||||
t.Cell()
|
||||
.Element(x => x.MinHeight(380)) // same floor as left
|
||||
.Element(e => SectionA_RightPane_EmailInternetShared(e, m));
|
||||
});
|
||||
col.Item().Element(e => FormArrangementBlock(e, m));
|
||||
col.Item().Element(e => SectionB_TwoBlocks(e, m));
|
||||
});
|
||||
});
|
||||
}).GeneratePdf();
|
||||
}
|
||||
|
||||
// ---------------- helpers & styles ----------------
|
||||
#region HELPERS
|
||||
static string Box(bool on) => on ? "☒" : "☐";
|
||||
static string F(DateTime? dt) => dt.HasValue ? dt.Value.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture) : "";
|
||||
static IContainer Cell(IContainer x) => x.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(4);
|
||||
static IContainer CellHead(IContainer x) => x.Background(Colors.Grey.Lighten4).Border(1).BorderColor(Colors.Grey.Lighten2).Padding(4);
|
||||
static IContainer CellMandatory(IContainer x) => x.Background(Colors.Grey.Lighten2).Border(1).BorderColor(Colors.Grey.Lighten2).Padding(4);
|
||||
#endregion
|
||||
|
||||
// ================= HEADER (boxed like your screenshot) =================
|
||||
void HeaderStrip(IContainer c, ItRequestReportModel m)
|
||||
{
|
||||
c.Border(1).Padding(0).Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(2); // LEFT: big title
|
||||
cols.RelativeColumn(1); // RIGHT: meta table
|
||||
});
|
||||
|
||||
// LEFT pane: centered title
|
||||
t.Cell().BorderRight(1).Padding(8).MinHeight(10).AlignCenter().AlignMiddle().Column(left =>
|
||||
{
|
||||
left.Item().Text("I.T. REQUEST FORM").SemiBold().FontSize(14).AlignCenter();
|
||||
left.Item().Text("(Group IT)").SemiBold().FontSize(14).AlignCenter();
|
||||
});
|
||||
|
||||
// RIGHT pane: 2-column grid with borders
|
||||
t.Cell().Padding(0).Element(x => RightMetaBox(x, m));
|
||||
});
|
||||
}
|
||||
|
||||
// draws the right-side 2-column table with boxed rows
|
||||
void RightMetaBox(IContainer c, ItRequestReportModel m)
|
||||
{
|
||||
c.Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(1); // label
|
||||
cols.RelativeColumn(1); // value
|
||||
});
|
||||
|
||||
void Row(string label, string value, bool isLast = false)
|
||||
{
|
||||
// label cell (left)
|
||||
t.Cell().BorderBottom(1).Padding(2).Text(label);
|
||||
// value cell (right) — add vertical divider between label/value
|
||||
t.Cell().BorderLeft(1).BorderBottom(1).Padding(0).AlignMiddle().Text(value ?? "");
|
||||
}
|
||||
|
||||
Row("Document No.", m.DocumentNo);
|
||||
Row("Effective Date", (m.EffectiveDate == default) ? "" : m.EffectiveDate.ToString("dd/MM/yyyy"));
|
||||
Row("Rev. No", m.RevNo); // <- shows dynamic StatusId value
|
||||
Row("Doc. Page No", m.DocPageNo); // last row still gets bottom border for boxed look
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// ================= SECTION A =================
|
||||
#region SECTION A • Identity & Employment
|
||||
// ================= SECTION A – Identity & Employment (refined layout) =================
|
||||
// SECTION A — Identity + Employment as ONE BLOCK (matches screenshot)
|
||||
// ================= SECTION A – Compact Unified Block (new refined layout) =================
|
||||
void SectionA_IdentityEmployment(IContainer c, ItRequestReportModel m)
|
||||
{
|
||||
// helpers
|
||||
var emp = (m.EmploymentStatus ?? "").Trim().ToLowerInvariant();
|
||||
bool isPerm = emp == "permanent";
|
||||
bool isContract = emp == "contract";
|
||||
bool isTemp = emp == "temp" || emp == "temporary";
|
||||
bool isNew = emp == "new staff" || emp == "new";
|
||||
|
||||
IContainer L(IContainer x) => x.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(4);
|
||||
IContainer V(IContainer x) => x.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(4);
|
||||
|
||||
c.Table(t =>
|
||||
{
|
||||
// 6-column consistent grid
|
||||
t.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(1); // L1
|
||||
cols.RelativeColumn(2); // V1
|
||||
cols.RelativeColumn(1); // L2
|
||||
cols.RelativeColumn(2); // V2
|
||||
cols.RelativeColumn(1); // L3
|
||||
cols.RelativeColumn(2); // V3
|
||||
});
|
||||
|
||||
// === Row 1: Staff Name / Company / Div ===
|
||||
t.Cell().Element(L).Text("Staff Name:");
|
||||
t.Cell().Element(V).Text(m.StaffName ?? "");
|
||||
t.Cell().Element(L).Text("Company:");
|
||||
t.Cell().Element(V).Text(m.CompanyName ?? "");
|
||||
t.Cell().Element(L).Text("Div/Dept:");
|
||||
t.Cell().Element(V).Text(m.DepartmentName ?? "");
|
||||
|
||||
// === Row 2: Designation / Location ===
|
||||
t.Cell().Element(L).Text("Designation:");
|
||||
t.Cell().Element(V).Text(m.Designation ?? "");
|
||||
t.Cell().Element(L).Text("Location:");
|
||||
t.Cell().Element(V).Text(m.Location ?? "");
|
||||
// fill the last two cells to maintain full-width grid
|
||||
t.Cell().Element(L).Text("");
|
||||
t.Cell().Element(V).Text("");
|
||||
|
||||
// === Row 3: Employment Status + Contract End Date beside it ===
|
||||
t.Cell().Element(L).Text("Employment Status:");
|
||||
t.Cell().ColumnSpan(3).Element(V).Row(r =>
|
||||
{
|
||||
r.Spacing(12);
|
||||
r.ConstantItem(50).Text($"{Box(isPerm)} Permanent");
|
||||
r.ConstantItem(50).Text($"{Box(isContract)} Contract");
|
||||
r.ConstantItem(50).Text($"{Box(isTemp)} Temp");
|
||||
r.ConstantItem(50).Text($"{Box(isNew)} New Staff");
|
||||
});
|
||||
|
||||
t.Cell().Element(L).Text("If Temp / Contract End Date:");
|
||||
t.Cell().Element(V).Text(F(m.ContractEndDate));
|
||||
|
||||
// === Row 4: Phone Ext + Required Date beside it ===
|
||||
t.Cell().Element(L).Text("Phone Ext:");
|
||||
t.Cell().Element(V).Text(m.PhoneExt ?? "");
|
||||
t.Cell().Element(L).Text("Required Date:");
|
||||
t.Cell().Element(V).Text(F(m.RequiredDate));
|
||||
// keep remaining two cells empty to close grid
|
||||
t.Cell().Element(L).Text("");
|
||||
t.Cell().Element(V).Text("");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
#region SECTION A • HardwareOsSoftware
|
||||
|
||||
// ===================== SECTION A – HARDWARE + OS + SOFTWARE =====================
|
||||
void SectionA_HardwareOsSoftware(IContainer c, ItRequestReportModel m)
|
||||
{
|
||||
bool HasSoft(string name) =>
|
||||
m.Software.Any(s => (s.Name ?? "").Equals(name, StringComparison.InvariantCultureIgnoreCase));
|
||||
|
||||
var general = new[] { "MS Word", "MS Excel", "MS Outlook", "MS PowerPoint", "MS Access", "MS Project", "Acrobat Standard", "AutoCAD", "Worktop/ERP Login" };
|
||||
var utility = new[] { "PDF Viewer", "7Zip", "AutoCAD Viewer", "Smart Draw" };
|
||||
|
||||
c.Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(1);
|
||||
});
|
||||
|
||||
// --- HEADER: Hardware Requirements ---
|
||||
t.Cell().Element(x => x.Background(Colors.Black).Padding(3))
|
||||
.Text("Hardware Requirements").FontColor(Colors.White).Bold();
|
||||
|
||||
// --- Hardware Body ---
|
||||
t.Cell().Border(1).Padding(1).Column(col =>
|
||||
{
|
||||
col.Spacing(5);
|
||||
col.Item().Row(r =>
|
||||
{
|
||||
// Left column: Purpose + Justification
|
||||
r.RelativeItem().Column(left =>
|
||||
{
|
||||
left.Item().Text($"{Box(m.HwPurposeNewRecruitment)} New Staff Recruitment");
|
||||
left.Item().Text($"{Box(m.HwPurposeReplacement)} Replacement");
|
||||
left.Item().Text($"{Box(m.HwPurposeAdditional)} Additional");
|
||||
left.Item().PaddingTop(5).Text("Justification (for hardware change) :");
|
||||
left.Item().Border(1).Height(40).Padding(4)
|
||||
.Text(string.IsNullOrWhiteSpace(m.Justification) ? "" : m.Justification);
|
||||
});
|
||||
|
||||
// Right column: Category selection
|
||||
r.RelativeItem().Column(right =>
|
||||
{
|
||||
right.Item().Text("Select below:");
|
||||
right.Item().Text($"{Box(m.HwDesktopAllIn)} Desktop (all inclusive)");
|
||||
right.Item().Text($"{Box(m.HwNotebookAllIn)} Notebook (all inclusive)");
|
||||
right.Item().Text($"{Box(m.HwDesktopOnly)} Desktop only");
|
||||
right.Item().Text($"{Box(m.HwNotebookOnly)} Notebook only");
|
||||
right.Item().Text($"{Box(m.HwNotebookBattery)} Notebook battery");
|
||||
right.Item().Text($"{Box(m.HwPowerAdapter)} Power Adapter");
|
||||
right.Item().Text($"{Box(m.HwMouse)} Computer Mouse");
|
||||
right.Item().Text($"{Box(m.HwExternalHdd)} External Hard Drive");
|
||||
right.Item().Text("Other (Specify):");
|
||||
right.Item().Border(1).Height(18).Padding(3)
|
||||
.Text(string.IsNullOrWhiteSpace(m.HwOtherText) ? "-" : m.HwOtherText);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- HEADER: OS Requirements ---
|
||||
t.Cell().Element(x => x.Background(Colors.Black).Padding(3))
|
||||
.Text("OS Requirements (Leave blank if no specific requirement)")
|
||||
.FontColor(Colors.White).Bold();
|
||||
|
||||
// --- OS Body ---
|
||||
t.Cell().Border(1).Padding(5).Column(col =>
|
||||
{
|
||||
col.Item().Text("Requirements:");
|
||||
col.Item().Border(1).Height(30).Padding(4)
|
||||
.Text(m.OsRequirements.Any() ? string.Join(Environment.NewLine, m.OsRequirements) : "-");
|
||||
});
|
||||
|
||||
// --- HEADER: Software Requirements ---
|
||||
t.Cell().Element(x => x.Background(Colors.Black).Padding(3))
|
||||
.Text("Software Requirements").FontColor(Colors.White).Bold();
|
||||
|
||||
// --- Software Body (3 columns) ---
|
||||
t.Cell().Border(1).Padding(5).Table(st =>
|
||||
{
|
||||
st.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(1); // General
|
||||
cols.RelativeColumn(1); // Utility
|
||||
cols.RelativeColumn(1); // Custom
|
||||
});
|
||||
|
||||
// Headings
|
||||
st.Header(h =>
|
||||
{
|
||||
h.Cell().Element(CellHead).Text("General Software");
|
||||
h.Cell().Element(CellHead).Text("Utility Software");
|
||||
h.Cell().Element(CellHead).Text("Custom Software");
|
||||
});
|
||||
|
||||
int maxRows = Math.Max(general.Length, utility.Length);
|
||||
for (int i = 0; i < maxRows; i++)
|
||||
{
|
||||
st.Cell().Element(Cell)
|
||||
.Text(i < general.Length ? $"{Box(HasSoft(general[i]))} {general[i]}" : "");
|
||||
st.Cell().Element(Cell)
|
||||
.Text(i < utility.Length ? $"{Box(HasSoft(utility[i]))} {utility[i]}" : "");
|
||||
|
||||
if (i == 0)
|
||||
{
|
||||
st.Cell().Element(Cell).Text("Others (Specify) :");
|
||||
}
|
||||
else if (i == 1)
|
||||
{
|
||||
st.Cell().Element(Cell).Border(1).Height(15).Padding(3).Text("-");
|
||||
}
|
||||
else st.Cell().Element(Cell).Text("");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
#region SECTION A • EmailInternetSharedperm
|
||||
// ===================== SECTION A – RIGHT PANE =====================
|
||||
void SectionA_RightPane_EmailInternetShared(IContainer c, ItRequestReportModel m)
|
||||
{
|
||||
c.Column(col =>
|
||||
{
|
||||
// ===== Email =====
|
||||
col.Item().Element(x => x.Background(Colors.Black).Padding(3))
|
||||
.Text("Email").FontColor(Colors.White).Bold();
|
||||
|
||||
col.Item().Border(1).Padding(6).Column(cc =>
|
||||
{
|
||||
// single-line "Email Address:" with inline box
|
||||
cc.Item().Row(r =>
|
||||
{
|
||||
r.ConstantItem(100).Text("Email Address:"); // label column width
|
||||
r.RelativeItem().Border(1).Height(16).PaddingHorizontal(4).AlignMiddle()
|
||||
.Text(m.Emails.Any() ? string.Join("; ", m.Emails) : "");
|
||||
});
|
||||
|
||||
cc.Item().PaddingTop(4).Text("Arrangement Guide: FirstName&LastName or Name&Surname or Surname&Name.");
|
||||
cc.Item().Text("Full name and short form/ initial is allowed to be used if the name is too long.");
|
||||
cc.Item().Text("e.g. siti.nurhaliza, jackie.chan, ks.chan");
|
||||
});
|
||||
|
||||
// ===== Internet Permissions =====
|
||||
col.Item().Element(x => x.Background(Colors.Black).Padding(3))
|
||||
.Text("Internet Permissions").FontColor(Colors.White).Bold();
|
||||
|
||||
col.Item().Border(1).Padding(0).Column(cc =>
|
||||
{
|
||||
cc.Item().BorderBottom(1).Padding(6).Text($"{Box(false)} Unlimited Internet Access");
|
||||
cc.Item().BorderBottom(1).Padding(6).Text($"{Box(false)} Limited Internet Access");
|
||||
cc.Item().BorderBottom(1).Padding(6).Text($"{Box(false)} PSI Instant Messenger");
|
||||
cc.Item().BorderBottom(1).Padding(6).Text($"{Box(false)} VPN");
|
||||
|
||||
cc.Item().PaddingLeft(14).PaddingTop(4).Text("Justification for the Internet Permissions:");
|
||||
cc.Item().Padding(6).Border(1).Height(20).Padding(4).Text("-");
|
||||
});
|
||||
|
||||
// ===== Shared Permissions =====
|
||||
col.Item().PaddingTop(0).Element(x => x.Background(Colors.Black).Padding(3))
|
||||
.Text("Shared Permissions").FontColor(Colors.White).Bold();
|
||||
|
||||
col.Item().Border(1).Padding(0).Element(x => SharedPermissionsTable(x, m));
|
||||
|
||||
// ===== Copier =====
|
||||
col.Item().PaddingTop(0).Element(x => x.Border(1).Padding(8)).Column(cc =>
|
||||
{
|
||||
cc.Item().Row(rr =>
|
||||
{
|
||||
rr.RelativeItem().Row(r1 =>
|
||||
{
|
||||
r1.RelativeItem().Text("Copier Scanner");
|
||||
r1.RelativeItem().Text($"{Box(true)} Black");
|
||||
r1.RelativeItem().Text($"{Box(false)} Color");
|
||||
});
|
||||
|
||||
rr.RelativeItem().Row(r2 =>
|
||||
{
|
||||
r2.RelativeItem().Text("Copier Printing");
|
||||
r2.RelativeItem().Text($"{Box(true)} Black");
|
||||
r2.RelativeItem().Text($"{Box(false)} Color");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
void SharedPermissionsTable(IContainer c, ItRequestReportModel m)
|
||||
{
|
||||
c.Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.ConstantColumn(18); // #
|
||||
cols.RelativeColumn(6); // Share
|
||||
cols.RelativeColumn(1); // Read
|
||||
cols.RelativeColumn(1); // Write
|
||||
cols.RelativeColumn(1); // Delete
|
||||
cols.RelativeColumn(1); // Remove
|
||||
});
|
||||
|
||||
// header band
|
||||
t.Header(h =>
|
||||
{
|
||||
h.Cell().Element(CellHead).Text("");
|
||||
h.Cell().Element(CellHead).Text("Shared Permissions");
|
||||
h.Cell().Element(CellHead).Text("Read");
|
||||
h.Cell().Element(CellHead).Text("Write");
|
||||
h.Cell().Element(CellHead).Text("Delete");
|
||||
h.Cell().Element(CellHead).Text("Remove");
|
||||
});
|
||||
|
||||
var rows = m.SharedPerms.Select((sp, i) => new
|
||||
{
|
||||
Index = i + 1,
|
||||
sp.Share,
|
||||
sp.R,
|
||||
sp.W,
|
||||
sp.D,
|
||||
sp.Remove
|
||||
}).ToList();
|
||||
|
||||
foreach (var r in rows)
|
||||
{
|
||||
t.Cell().Element(Cell).Text(r.Index.ToString());
|
||||
t.Cell().Element(Cell).Text(r.Share ?? "");
|
||||
t.Cell().Element(Cell).Text(Box(r.R));
|
||||
t.Cell().Element(Cell).Text(Box(r.W));
|
||||
t.Cell().Element(Cell).Text(Box(r.D));
|
||||
t.Cell().Element(Cell).Text(Box(r.Remove));
|
||||
}
|
||||
|
||||
int start = rows.Count + 1; // no hardcoded row; if no data, start at 1
|
||||
|
||||
for (int i = start; i <= 6; i++)
|
||||
{
|
||||
t.Cell().Element(Cell).Text(i.ToString());
|
||||
t.Cell().Element(Cell).Text("");
|
||||
t.Cell().Element(Cell).Text("");
|
||||
t.Cell().Element(Cell).Text("");
|
||||
t.Cell().Element(Cell).Text("");
|
||||
t.Cell().Element(Cell).Text("");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
#region SECTION A • OS Requirements
|
||||
// ===================== SECTION A – Form Arrangement =====================
|
||||
|
||||
void FormArrangementBlock(IContainer c, ItRequestReportModel m)
|
||||
{
|
||||
// tiny helper: draws the vertical borders for each cell so it looks like 5 boxed panels
|
||||
IContainer BoxCell(IContainer x, bool isLast) =>
|
||||
x.BorderLeft(1)
|
||||
.BorderRight(isLast ? 1 : 0) // last cell closes the right border
|
||||
.Padding(6)
|
||||
.MinHeight(45); // keep all equal; adjust to taste
|
||||
|
||||
c.Column(col =>
|
||||
{
|
||||
// italic guide line
|
||||
col.Item()
|
||||
.PaddingBottom(4)
|
||||
.Text("Form Arrangement: User → HOD → IT HOD → FINANCE HOD → CEO/CFO/COO")
|
||||
.Italic();
|
||||
|
||||
// OUTER frame
|
||||
col.Item().Border(1).Element(frame =>
|
||||
{
|
||||
frame.Table(t =>
|
||||
{
|
||||
// 5 equal columns
|
||||
t.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(1);
|
||||
cols.RelativeColumn(1);
|
||||
cols.RelativeColumn(1);
|
||||
cols.RelativeColumn(1);
|
||||
cols.RelativeColumn(1);
|
||||
});
|
||||
|
||||
// local function to render a boxed panel
|
||||
void Panel(int index0to4, string title, string name, string date)
|
||||
{
|
||||
bool last = index0to4 == 4;
|
||||
t.Cell().Element(x => BoxCell(x, last)).Column(cc =>
|
||||
{
|
||||
cc.Item().Text(title);
|
||||
|
||||
// signature line
|
||||
|
||||
|
||||
// Name / Date rows
|
||||
cc.Item().PaddingTop(8).Row(r =>
|
||||
{
|
||||
r.ConstantItem(40).Text("Name :");
|
||||
r.RelativeItem().Text(string.IsNullOrWhiteSpace(name) ? "" : name);
|
||||
});
|
||||
cc.Item().Row(r =>
|
||||
{
|
||||
r.ConstantItem(40).Text("Date :");
|
||||
r.RelativeItem().Text(string.IsNullOrWhiteSpace(date) ? "" : date);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// build the 5 panels (plug in your resolved names/dates)
|
||||
Panel(0, "Requested by:", m.RequestorName ?? "", F(m.SubmitDate) ?? "");
|
||||
Panel(1, "Approved by HOD:", m.HodApprovedBy ?? "", F(m.HodSubmitDate) ?? "");
|
||||
Panel(2, "Approved by Group IT HOD:", m.GitHodApprovedBy ?? "", F(m.GitHodSubmitDate) ?? "");
|
||||
Panel(3, "Supported by Finance HOD:", m.FinHodApprovedBy ?? "", F(m.FinHodSubmitDate) ?? "");
|
||||
Panel(4, "Reviewed & Approved by Management:", m.MgmtApprovedBy ?? "", F(m.MgmtSubmitDate) ?? "");
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region SECTION A • Approvals
|
||||
void Approvals(IContainer c, ItRequestReportModel m)
|
||||
{
|
||||
c.Column(col =>
|
||||
{
|
||||
col.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem().Column(cc =>
|
||||
{
|
||||
cc.Item().Text("Requested by:");
|
||||
cc.Item().PaddingTop(12).Text("Name : MANDATORY");
|
||||
cc.Item().Row(rr =>
|
||||
{
|
||||
rr.ConstantItem(40).Text("Date :");
|
||||
rr.RelativeItem().Border(1).Height(14).Text(F(m.SubmitDate));
|
||||
});
|
||||
});
|
||||
|
||||
r.RelativeItem().Column(cc =>
|
||||
{
|
||||
cc.Item().Text("Approved by HOD:");
|
||||
cc.Item().PaddingTop(12).Text("Name :");
|
||||
cc.Item().Row(rr =>
|
||||
{
|
||||
rr.ConstantItem(40).Text("Date :");
|
||||
rr.RelativeItem().Border(1).Height(16).Text("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
col.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem().Column(cc =>
|
||||
{
|
||||
cc.Item().Text("Approved by Group IT HOD:");
|
||||
cc.Item().PaddingTop(12).Text("Name :");
|
||||
cc.Item().Row(rr =>
|
||||
{
|
||||
rr.ConstantItem(40).Text("Date :");
|
||||
rr.RelativeItem().Border(1).Height(16).Text("");
|
||||
});
|
||||
});
|
||||
|
||||
r.RelativeItem().Column(cc =>
|
||||
{
|
||||
cc.Item().Text("Supported by Finance HOD:");
|
||||
cc.Item().PaddingTop(12).Text("Name :");
|
||||
cc.Item().Row(rr =>
|
||||
{
|
||||
rr.ConstantItem(40).Text("Date :");
|
||||
rr.RelativeItem().Border(1).Height(16).Text("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
col.Item().Row(r =>
|
||||
{
|
||||
r.RelativeItem().Column(cc =>
|
||||
{
|
||||
cc.Item().Text("Reviewed & Approved by");
|
||||
cc.Item().Text("Management:");
|
||||
cc.Item().PaddingTop(12).Text("Name :");
|
||||
cc.Item().Row(rr =>
|
||||
{
|
||||
rr.ConstantItem(40).Text("Date :");
|
||||
rr.RelativeItem().Border(1).Height(16).Text("");
|
||||
});
|
||||
});
|
||||
|
||||
r.RelativeItem().Text("");
|
||||
});
|
||||
});
|
||||
}
|
||||
#endregion
|
||||
|
||||
// ================= SECTION B =================
|
||||
#region SECTION B • Asset Information/ Acknowledgment
|
||||
|
||||
void SectionB_TwoBlocks(IContainer c, ItRequestReportModel m)
|
||||
{
|
||||
c.Column(col =>
|
||||
{
|
||||
// Title line exactly like screenshot (italic note on same line)
|
||||
col.Item().Text(text =>
|
||||
{
|
||||
text.Span("Section B ").Bold();
|
||||
text.Span("(To be completed by IT Staff only)").Italic();
|
||||
});
|
||||
|
||||
// ===== Block 1: Asset Information (left) + Remarks (right) =====
|
||||
col.Item().PaddingTop(4).Border(1).Element(block1 =>
|
||||
{
|
||||
block1.Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(1);
|
||||
cols.ConstantColumn(1); // skinny divider (we'll draw borders on cells)
|
||||
cols.RelativeColumn(1);
|
||||
});
|
||||
|
||||
// Header row with black bands
|
||||
// Left header
|
||||
t.Cell().Element(x => x.Background(Colors.Black).Padding(3).BorderRight(1))
|
||||
.Text("Asset Information").FontColor(Colors.White).Bold();
|
||||
// divider (invisible content, keeps structure)
|
||||
t.Cell().BorderLeft(0).BorderRight(0).Text("");
|
||||
// Right header
|
||||
t.Cell().Element(x => x.Background(Colors.Black).Padding(3))
|
||||
.Text("Remarks:-").FontColor(Colors.White).Bold();
|
||||
|
||||
// Content row (equal height because same table row)
|
||||
// Left: asset info table
|
||||
t.Cell().Element(x => x.BorderTop(1).BorderRight(1).Padding(0))
|
||||
.Element(x => SectionB_AssetInfoTable(x, m));
|
||||
|
||||
// divider
|
||||
t.Cell().Border(0).Text("");
|
||||
|
||||
// Right: remarks box
|
||||
t.Cell().Element(x => x.BorderTop(1).Padding(6).MinHeight(108))
|
||||
.Text(string.IsNullOrWhiteSpace(m.Remarks) ? "" : m.Remarks);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Block 2: Requestor Acknowledgement (left) + Completed By (right) =====
|
||||
// ===== Block 2: Requestor Acknowledgement (left) + Completed By (right) =====
|
||||
col.Item().PaddingTop(6).Border(1).Element(block2 =>
|
||||
{
|
||||
block2.Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(1);
|
||||
cols.ConstantColumn(1); // divider
|
||||
cols.RelativeColumn(1);
|
||||
});
|
||||
|
||||
// Left pane (no signature line, compact height)
|
||||
t.Cell().Element(x => x.Padding(8).BorderRight(1)).Column(cc =>
|
||||
{
|
||||
cc.Item().Text("Requestor Acknowledgement:").Bold();
|
||||
cc.Item().PaddingTop(4).Row(r =>
|
||||
{
|
||||
r.ConstantItem(44).Text("Name:");
|
||||
r.RelativeItem().Text(m.RequestorName ?? "");
|
||||
r.ConstantItem(44).Text("Date:");
|
||||
r.RelativeItem().Text(F(m.RequestorAcceptedAt));
|
||||
});
|
||||
});
|
||||
|
||||
// divider
|
||||
t.Cell().Border(0).Text("");
|
||||
|
||||
// Right pane (no signature line)
|
||||
t.Cell().Element(x => x.Padding(8)).Column(cc =>
|
||||
{
|
||||
cc.Item().Text("Completed by:").Bold();
|
||||
cc.Item().PaddingTop(4).Row(r =>
|
||||
{
|
||||
r.ConstantItem(44).Text("Name:");
|
||||
r.RelativeItem().Text(m.ItCompletedBy ?? "");
|
||||
r.ConstantItem(44).Text("Date:");
|
||||
r.RelativeItem().Text(F(m.ItAcceptedAt));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
// Left-pane table used in Block 1 (matches your rows & borders)
|
||||
void SectionB_AssetInfoTable(IContainer c, ItRequestReportModel m)
|
||||
{
|
||||
c.Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(2); // label
|
||||
cols.RelativeColumn(2); // value box
|
||||
});
|
||||
|
||||
void Row(string label, string value)
|
||||
{
|
||||
t.Cell().BorderBottom(1).Padding(6).Text(label);
|
||||
t.Cell().BorderLeft(1).BorderBottom(1).Padding(3).Text(value ?? "");
|
||||
}
|
||||
|
||||
// top row gets its own bottom borders; left cell also has right divider
|
||||
Row("Asset No:", m.AssetNo);
|
||||
Row("Machine ID:", m.MachineId);
|
||||
Row("IP Add:", m.IpAddress);
|
||||
Row("Wired Mac Add:", m.WiredMac);
|
||||
Row("Wi-Fi Mac Add:", m.WifiMac);
|
||||
Row("Dial-up Acc:", m.DialupAcc);
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
79
Areas/IT/Printing/ItRequestReportModel.cs
Normal file
79
Areas/IT/Printing/ItRequestReportModel.cs
Normal file
@ -0,0 +1,79 @@
|
||||
namespace PSTW_CentralSystem.Areas.IT.Printing
|
||||
{
|
||||
public class ItRequestReportModel
|
||||
{
|
||||
// Header/meta
|
||||
public string DocumentNo { get; set; } = "GITRF_01";
|
||||
public string RevNo { get; set; } = "";
|
||||
public string DocPageNo { get; set; } = "1 of 1";
|
||||
public DateTime EffectiveDate { get; set; } = DateTime.Today;
|
||||
|
||||
// Section A – Requestor snapshot
|
||||
public string StaffName { get; set; } = "";
|
||||
public string CompanyName { get; set; } = "";
|
||||
public string DepartmentName { get; set; } = "";
|
||||
public string Designation { get; set; } = "";
|
||||
public string Location { get; set; } = "";
|
||||
public string EmploymentStatus { get; set; } = "";
|
||||
public DateTime? ContractEndDate { get; set; }
|
||||
public DateTime RequiredDate { get; set; }
|
||||
public string PhoneExt { get; set; } = "";
|
||||
|
||||
// Captured lists (kept for other sections)
|
||||
public List<string> Hardware { get; set; } = new();
|
||||
public string? Justification { get; set; }
|
||||
public List<string> Emails { get; set; } = new();
|
||||
public List<string> OsRequirements { get; set; } = new();
|
||||
public List<(string Bucket, string Name, string? Other, string? Notes)> Software { get; set; } = new();
|
||||
public List<(string Share, bool R, bool W, bool D, bool Remove)> SharedPerms { get; set; } = new();
|
||||
|
||||
// ===== NEW: Hardware purposes (left column) =====
|
||||
public bool HwPurposeNewRecruitment { get; set; }
|
||||
public bool HwPurposeReplacement { get; set; }
|
||||
public bool HwPurposeAdditional { get; set; }
|
||||
|
||||
// ===== NEW: Hardware selections (right column) =====
|
||||
public bool HwDesktopAllIn { get; set; }
|
||||
public bool HwNotebookAllIn { get; set; }
|
||||
public bool HwDesktopOnly { get; set; }
|
||||
public bool HwNotebookOnly { get; set; }
|
||||
public bool HwNotebookBattery { get; set; }
|
||||
public bool HwPowerAdapter { get; set; }
|
||||
public bool HwMouse { get; set; }
|
||||
public bool HwExternalHdd { get; set; }
|
||||
public string? HwOtherText { get; set; }
|
||||
|
||||
// Section B – IT staff
|
||||
public string AssetNo { get; set; } = "";
|
||||
public string MachineId { get; set; } = "";
|
||||
public string IpAddress { get; set; } = "";
|
||||
public string WiredMac { get; set; } = "";
|
||||
public string WifiMac { get; set; } = "";
|
||||
public string DialupAcc { get; set; } = "";
|
||||
public string Remarks { get; set; } = "";
|
||||
|
||||
// Acceptance
|
||||
public string RequestorName { get; set; } = "";
|
||||
public DateTime? RequestorAcceptedAt { get; set; }
|
||||
public string ItCompletedBy { get; set; } = "";
|
||||
public DateTime? ItAcceptedAt { get; set; }
|
||||
|
||||
// Status
|
||||
public int ItRequestId { get; set; }
|
||||
public int StatusId { get; set; }
|
||||
public string OverallStatus { get; set; } = "";
|
||||
public DateTime SubmitDate { get; set; }
|
||||
|
||||
// Approval Flow dates
|
||||
public DateTime? HodSubmitDate { get; set; }
|
||||
public DateTime? GitHodSubmitDate { get; set; }
|
||||
public DateTime? FinHodSubmitDate { get; set; }
|
||||
public DateTime? MgmtSubmitDate { get; set; }
|
||||
|
||||
// Approvers
|
||||
public string HodApprovedBy { get; set; }
|
||||
public string GitHodApprovedBy { get; set; }
|
||||
public string FinHodApprovedBy { get; set; }
|
||||
public string MgmtApprovedBy { get; set; }
|
||||
}
|
||||
}
|
||||
573
Areas/IT/Views/ApprovalDashboard/Admin.cshtml
Normal file
573
Areas/IT/Views/ApprovalDashboard/Admin.cshtml
Normal file
@ -0,0 +1,573 @@
|
||||
@{
|
||||
ViewData["Title"] = "IT Request Assignments";
|
||||
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||
}
|
||||
|
||||
<!-- Bootstrap Icons (remove if already added 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;
|
||||
}
|
||||
|
||||
/* === Lists & chips (shared by IT Team & Managers) === */
|
||||
.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">Approval Flows</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>
|
||||
<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 -->
|
||||
<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 -->
|
||||
<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>
|
||||
|
||||
<!-- ===================== REQUEST FORM MANAGERS CARD ===================== -->
|
||||
<div class="card mt-3 it-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h6 class="m-0">Request Form Managers</h6>
|
||||
<button class="btn btn-sm btn-primary" @@click="saveFormManagers" :disabled="savingManagers">
|
||||
{{ savingManagers ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="text-muted mb-3">
|
||||
<small>Select users who can access Assignings/Admin features (manage request assignments, etc.).</small>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 align-items-start">
|
||||
<!-- LEFT -->
|
||||
<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="rfmSearch">
|
||||
</div>
|
||||
|
||||
<div class="it-list">
|
||||
<label v-for="u in filteredUsersForManagers" :key="'rfm-avail-'+u.id" class="it-item">
|
||||
<input type="checkbox" :value="u.id" v-model="rfmUserIds">
|
||||
<span class="it-name">{{ u.name }}</span>
|
||||
</label>
|
||||
<div v-if="!filteredUsersForManagers.length" class="text-muted small p-2">No users match your search.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT -->
|
||||
<div class="col-md-5">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<strong>Selected ({{ selectedManagers.length }})</strong>
|
||||
<button class="btn btn-link btn-sm text-decoration-none"
|
||||
@@click="rfmUserIds = []"
|
||||
:disabled="!selectedManagers.length">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="it-selected">
|
||||
<span v-for="u in selectedManagers" :key="'rfm-sel-'+u.id" class="chip">
|
||||
{{ u.name }}
|
||||
<button class="chip-x" @@click="removeRfm(u.id)" aria-label="Remove">×</button>
|
||||
</span>
|
||||
<div v-if="!selectedManagers.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 for display (id + name)
|
||||
// IT Team state
|
||||
itTeamUserIds: [],
|
||||
itSearch: '',
|
||||
savingTeam: false,
|
||||
// Request Form Managers state
|
||||
rfmUserIds: [],
|
||||
rfmSearch: '',
|
||||
savingManagers: false,
|
||||
// UI state
|
||||
busy: false,
|
||||
error: null,
|
||||
saving: false,
|
||||
formError: null,
|
||||
form: {
|
||||
itApprovalFlowId: null,
|
||||
flowName: '',
|
||||
hodUserId: null,
|
||||
groupItHodUserId: null,
|
||||
finHodUserId: null,
|
||||
mgmtUserId: null
|
||||
},
|
||||
bsModal: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
/* IT Team list filter */
|
||||
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));
|
||||
},
|
||||
/* Managers list filter */
|
||||
filteredUsersForManagers() {
|
||||
const q = (this.rfmSearch || '').toLowerCase();
|
||||
if (!q) return this.users;
|
||||
return this.users.filter(u => (u.name || '').toLowerCase().includes(q));
|
||||
},
|
||||
selectedManagers() {
|
||||
const set = new Set(this.rfmUserIds);
|
||||
return this.users.filter(u => set.has(u.id));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
/* ===== Flows ===== */
|
||||
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();
|
||||
},
|
||||
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(); },
|
||||
|
||||
/* ===== IT Team endpoints ===== */
|
||||
async loadItTeam() {
|
||||
const r = await fetch('/ItRequestAPI/itTeam');
|
||||
this.itTeamUserIds = await r.json(); // array<int>
|
||||
},
|
||||
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;
|
||||
}
|
||||
},
|
||||
removeIt(uid) {
|
||||
this.itTeamUserIds = this.itTeamUserIds.filter(id => id !== uid);
|
||||
},
|
||||
|
||||
/* ===== Request Form Managers endpoints ===== */
|
||||
async loadFormManagers() {
|
||||
const r = await fetch('/ItRequestAPI/formManagers');
|
||||
this.rfmUserIds = await r.json(); // array<int>
|
||||
},
|
||||
async saveFormManagers() {
|
||||
try {
|
||||
this.savingManagers = true;
|
||||
const r = await fetch('/ItRequestAPI/formManagers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ userIds: this.rfmUserIds })
|
||||
});
|
||||
if (!r.ok) {
|
||||
const j = await r.json().catch(() => ({}));
|
||||
throw new Error(j.message || `Save failed (${r.status})`);
|
||||
}
|
||||
alert('Request Form Managers updated.');
|
||||
} catch (e) {
|
||||
alert(e.message || 'Failed to update Request Form Managers.');
|
||||
} finally {
|
||||
this.savingManagers = false;
|
||||
}
|
||||
},
|
||||
removeRfm(uid) {
|
||||
this.rfmUserIds = this.rfmUserIds.filter(id => id !== uid);
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await Promise.all([
|
||||
this.load(),
|
||||
this.loadUsers(),
|
||||
this.loadItTeam(),
|
||||
this.loadFormManagers()
|
||||
]);
|
||||
}
|
||||
});
|
||||
flowApp.mount('#flowApp');
|
||||
</script>
|
||||
475
Areas/IT/Views/ApprovalDashboard/Approval.cshtml
Normal file
475
Areas/IT/Views/ApprovalDashboard/Approval.cshtml
Normal file
@ -0,0 +1,475 @@
|
||||
@{
|
||||
ViewData["Title"] = "IT Request Approval Board";
|
||||
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||
}
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
|
||||
<style>
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,.06);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.table-container th,
|
||||
.table-container td {
|
||||
word-wrap: break-word;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.filters .form-label {
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nav-tabs .badge {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.status-badges .badge + .badge {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
border: 1px solid #e5e7eb;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 800;
|
||||
margin: 18px 0 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="app" style="max-width:1300px; margin:auto; font-size:13px;">
|
||||
<h3 class="mb-2 fw-bold"></h3>
|
||||
<p class="text-muted mb-3" style="margin-top:-6px;">Manage approvals and track Section B progress by month.</p>
|
||||
|
||||
<!-- Filters (shared by both tables) -->
|
||||
<div class="row mb-3 align-items-end filters">
|
||||
<div class="col-md-auto me-3">
|
||||
<label class="form-label">Month</label>
|
||||
<select class="form-control form-control-sm" v-model="selectedMonth" @@change="onPeriodChange">
|
||||
<option v-for="(m,i) in months" :key="i" :value="i+1">{{ m }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-auto">
|
||||
<label class="form-label">Year</label>
|
||||
<select class="form-control form-control-sm" v-model="selectedYear" @@change="onPeriodChange">
|
||||
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
|
||||
<div v-if="busy" class="alert alert-secondary py-2">Loading…</div>
|
||||
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- TABLE 1: Approvals Board -->
|
||||
<!-- ========================= -->
|
||||
<template v-if="isApprover">
|
||||
<h5 class="section-title">Approvals</h5>
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" :class="{ active: activeTab==='pending' }" href="#" @@click.prevent="switchTab('pending')">
|
||||
Pending <span class="badge bg-warning text-dark">{{ pendingActionsCount }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" :class="{ active: activeTab==='completed' }" href="#" @@click.prevent="switchTab('completed')">
|
||||
Completed <span class="badge bg-info">{{ completedActionsCount }}</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="table-container table-responsive">
|
||||
<table class="table table-bordered table-sm table-striped align-middle text-center">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Staff Name</th>
|
||||
<th>Department</th>
|
||||
<th>Date Submitted</th>
|
||||
<th>Stage / Role</th>
|
||||
<th>Your Status</th>
|
||||
<th style="width:260px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in paginatedData" :key="row.statusId">
|
||||
<td>{{ row.staffName }}</td>
|
||||
<td>{{ row.departmentName }}</td>
|
||||
<td>{{ formatDate(row.submitDate) }}</td>
|
||||
<td><span class="badge bg-light text-dark">{{ row.role }}</span></td>
|
||||
<td class="status-badges">
|
||||
<span :class="getStatusBadgeClass(row.currentUserStatus)">{{ row.currentUserStatus }}</span>
|
||||
<span v-if="row.isOverallRejected && !['Approved','Rejected'].includes(row.currentUserStatus)" class="badge bg-danger">Rejected earlier</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex justify-content-center align-items-center">
|
||||
<template v-if="activeTab==='pending'">
|
||||
<button v-if="row.canApprove" class="btn btn-success btn-sm me-1" @@click="updateStatus(row.statusId,'Approved')" :disabled="busy">Approve</button>
|
||||
<button v-if="row.canApprove" class="btn btn-danger btn-sm me-1" @@click="updateStatus(row.statusId,'Rejected')" :disabled="busy">Reject</button>
|
||||
<!-- removed 'awaiting previous stage' badge entirely -->
|
||||
</template>
|
||||
<button class="btn btn-primary btn-sm" @@click="viewRequest(row.statusId)">View</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!paginatedData.length"><td colspan="6" class="text-muted">No {{ activeTab }} requests</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination (Approvals) -->
|
||||
<div class="d-flex justify-content-between align-items-center mt-2" v-if="filteredData.length">
|
||||
<small class="text-muted">
|
||||
Showing {{ (currentPage-1)*itemsPerPage + 1 }} – {{ Math.min(currentPage*itemsPerPage, filteredData.length) }} of {{ filteredData.length }}
|
||||
</small>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-outline-secondary btn-sm" :disabled="currentPage===1" @@click="currentPage--">Prev</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" :disabled="currentPage*itemsPerPage>=filteredData.length" @@click="currentPage++">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- ========================= -->
|
||||
<!-- TABLE 2: Section B Board -->
|
||||
<!-- ========================= -->
|
||||
<template v-if="isItMember">
|
||||
<h5 class="section-title">Section B</h5>
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" :class="{ active: activeSbTab==='draft' }" href="#" @@click.prevent="switchSbTab('draft')">
|
||||
Draft <span class="badge bg-info text-dark">{{ sbCountDraft }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" :class="{ active: activeSbTab==='pending' }" href="#" @@click.prevent="switchSbTab('pending')">
|
||||
Pending <span class="badge bg-secondary">{{ sbCountPending }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" :class="{ active: activeSbTab==='awaiting' }" href="#" @@click.prevent="switchSbTab('awaiting')">
|
||||
Awaiting <span class="badge bg-warning text-dark">{{ sbCountAwaiting }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" :class="{ active: activeSbTab==='complete' }" href="#" @@click.prevent="switchSbTab('complete')">
|
||||
Complete <span class="badge bg-success">{{ sbCountComplete }}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
<div class="table-container table-responsive">
|
||||
<table class="table table-bordered table-sm table-striped align-middle text-center">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Staff Name</th>
|
||||
<th>Department</th>
|
||||
<th>Approved On</th>
|
||||
<th>Section B Stage</th>
|
||||
<th style="width:360px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in sbPaginated" :key="r.statusId">
|
||||
<td>{{ r.staffName }}</td>
|
||||
<td>{{ r.departmentName }}</td>
|
||||
<td>{{ r.approvedAt ? formatDate(r.approvedAt) : '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="stageBadge(r.stage).cls">{{ stageBadge(r.stage).text }}</span>
|
||||
|
||||
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex justify-content-center align-items-center flex-wrap" style="gap:6px;">
|
||||
|
||||
<!-- 1️⃣ Pending -->
|
||||
<button v-if="r.stage==='PENDING'"
|
||||
class="btn btn-outline-dark btn-sm"
|
||||
@@click="openSectionB(r.statusId)">
|
||||
Start Section B
|
||||
</button>
|
||||
|
||||
<!-- DRAFT -->
|
||||
<button v-if="r.stage==='DRAFT'"
|
||||
class="btn btn-outline-primary btn-sm"
|
||||
@@click="openSectionBEdit(r.statusId)">
|
||||
Continue
|
||||
</button>
|
||||
|
||||
<!-- AWAITING -->
|
||||
<button v-if="r.stage==='AWAITING' && !r.sb.itAccepted"
|
||||
class="btn btn-success btn-sm"
|
||||
@@click="acceptIt(r.statusId)">
|
||||
Accept
|
||||
</button>
|
||||
<button v-if="r.stage==='AWAITING' && r.sb.itAccepted"
|
||||
class="btn btn-primary btn-sm"
|
||||
@@click="openSectionB(r.statusId)">
|
||||
Review Section B
|
||||
</button>
|
||||
|
||||
|
||||
<!-- 4️⃣ Complete -->
|
||||
<button v-if="r.stage==='COMPLETE'"
|
||||
class="btn btn-primary btn-sm"
|
||||
@@click="openSectionB(r.statusId)">
|
||||
View
|
||||
</button>
|
||||
<button v-if="r.stage==='COMPLETE'"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
@@click="downloadPdf(r.statusId)">
|
||||
PDF
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr v-if="!busy && sbFiltered.length===0">
|
||||
<td colspan="5" class="text-muted text-center"><i class="bi bi-inboxes"></i> No Section B items in this tab</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination (Section B) -->
|
||||
<div class="d-flex justify-content-between align-items-center mt-2" v-if="sbFiltered.length">
|
||||
<small class="text-muted">
|
||||
Showing {{ (sectionBPageIndex-1)*itemsPerPage + 1 }} – {{ Math.min(sectionBPageIndex*itemsPerPage, sbFiltered.length) }} of {{ sbFiltered.length }}
|
||||
</small>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-outline-secondary btn-sm" :disabled="sectionBPageIndex===1" @@click="sectionBPageIndex--">Prev</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" :disabled="sectionBPageIndex*itemsPerPage>=sbFiltered.length" @@click="sectionBPageIndex++">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const app = Vue.createApp({
|
||||
data() {
|
||||
const now = new Date();
|
||||
return {
|
||||
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
|
||||
years: Array.from({ length: 10 }, (_, i) => now.getFullYear() - 5 + i),
|
||||
selectedMonth: now.getMonth() + 1,
|
||||
selectedYear: now.getFullYear(),
|
||||
|
||||
// Table 1 (Approvals)
|
||||
itStatusList: [],
|
||||
activeTab: 'pending',
|
||||
currentPage: 1,
|
||||
isApprover: false,
|
||||
approverChecked: false,
|
||||
|
||||
// Table 2 (Section B)
|
||||
sectionBList: [],
|
||||
activeSbTab: 'draft',
|
||||
sectionBPageIndex: 1,
|
||||
isItMember: false,
|
||||
sbChecked: false,
|
||||
|
||||
// Shared
|
||||
itemsPerPage: 10,
|
||||
busy: false,
|
||||
error: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
/* ======= Table 1: Approvals ======= */
|
||||
filteredData() {
|
||||
if (this.activeTab === 'pending') {
|
||||
// show only items the current approver can act on now
|
||||
return this.itStatusList.filter(r => r.canApprove === true);
|
||||
}
|
||||
// completed: only those where this approver already decided
|
||||
return this.itStatusList.filter(r => ['Approved', 'Rejected'].includes(r.currentUserStatus));
|
||||
},
|
||||
|
||||
paginatedData() {
|
||||
const start = (this.currentPage - 1) * this.itemsPerPage;
|
||||
return this.filteredData.slice(start, start + this.itemsPerPage);
|
||||
},
|
||||
pendingActionsCount() { return this.itStatusList.filter(r => r.canApprove).length; },
|
||||
completedActionsCount() { return this.itStatusList.filter(r => ['Approved', 'Rejected'].includes(r.currentUserStatus)).length; },
|
||||
|
||||
/* ======= Table 2: Section B ======= */
|
||||
sbFiltered() {
|
||||
const map = { draft: 'DRAFT', pending: 'PENDING', awaiting: 'AWAITING', complete: 'COMPLETE'};
|
||||
const want = map[this.activeSbTab];
|
||||
return this.sectionBList.filter(x => x.stage === want);
|
||||
},
|
||||
sbPaginated() {
|
||||
const start = (this.sectionBPageIndex - 1) * this.itemsPerPage;
|
||||
return this.sbFiltered.slice(start, start + this.itemsPerPage);
|
||||
},
|
||||
sbCountDraft() { return this.sectionBList.filter(x => x.stage === 'DRAFT').length; },
|
||||
sbCountPending() { return this.sectionBList.filter(x => x.stage === 'PENDING').length; },
|
||||
sbCountAwaiting() { return this.sectionBList.filter(x => x.stage === 'AWAITING').length; },
|
||||
sbCountComplete() { return this.sectionBList.filter(x => x.stage === 'COMPLETE').length; },
|
||||
|
||||
},
|
||||
methods: {
|
||||
/* ======= Shared helpers ======= */
|
||||
formatDate(str) { if (!str) return ''; const d = new Date(str); return isNaN(d) ? str : d.toLocaleDateString(); },
|
||||
getStatusBadgeClass(status) {
|
||||
switch ((status || '').toLowerCase()) {
|
||||
case 'approved': return 'badge bg-success';
|
||||
case 'rejected': return 'badge bg-danger';
|
||||
case 'pending': return 'badge bg-warning text-dark';
|
||||
default: return 'badge bg-secondary';
|
||||
}
|
||||
},
|
||||
stageBadge(stage) {
|
||||
switch (stage) {
|
||||
case 'COMPLETE': return { text: 'Complete', cls: 'bg-success' };
|
||||
case 'PENDING': return { text: 'Pending', cls: 'bg-secondary' };
|
||||
case 'DRAFT': return { text: 'Draft', cls: 'bg-info text-dark' };
|
||||
case 'AWAITING': return { text: 'Awaiting Acceptances', cls: 'bg-warning text-dark' };
|
||||
case 'NOT_ELIGIBLE': return { text: 'Not Eligible', cls: 'bg-dark' };
|
||||
default: return { text: stage, cls: 'bg-secondary' };
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
/* ======= Filters / Tabs ======= */
|
||||
onPeriodChange() {
|
||||
// Reload both; each loader sets its own access flags
|
||||
this.loadApprovals();
|
||||
this.loadSectionB();
|
||||
},
|
||||
switchTab(tab) {
|
||||
this.activeTab = tab;
|
||||
this.currentPage = 1;
|
||||
},
|
||||
switchSbTab(tab) {
|
||||
this.activeSbTab = tab;
|
||||
this.sectionBPageIndex = 1;
|
||||
},
|
||||
|
||||
/* ======= Data loaders ======= */
|
||||
async loadApprovals() {
|
||||
try {
|
||||
this.error = null;
|
||||
const r = await fetch(`/ItRequestAPI/pending?month=${this.selectedMonth}&year=${this.selectedYear}`);
|
||||
if (!r.ok) throw new Error(`Load failed (${r.status})`);
|
||||
const j = await r.json();
|
||||
const roles = (j && (j.roles || j.Roles)) || [];
|
||||
this.isApprover = Array.isArray(roles) && roles.length > 0;
|
||||
this.approverChecked = true;
|
||||
this.itStatusList = (j && (j.data || j.Data)) || [];
|
||||
this.currentPage = 1;
|
||||
} catch (e) {
|
||||
this.error = e.message || 'Failed to load approvals.';
|
||||
this.isApprover = false;
|
||||
this.approverChecked = true;
|
||||
this.itStatusList = [];
|
||||
}
|
||||
},
|
||||
async loadSectionB() {
|
||||
try {
|
||||
this.error = null;
|
||||
const r = await fetch(`/ItRequestAPI/sectionB/approvedList?month=${this.selectedMonth}&year=${this.selectedYear}`);
|
||||
if (!r.ok) throw new Error(`Section B list failed (${r.status})`);
|
||||
const j = await r.json();
|
||||
this.isItMember = !!(j && j.isItMember);
|
||||
this.sbChecked = true;
|
||||
this.sectionBList = (j && (j.data || j.Data)) || [];
|
||||
this.sectionBPageIndex = 1;
|
||||
} catch (e) {
|
||||
this.error = e.message || 'Failed to load Section B list.';
|
||||
this.isItMember = false;
|
||||
this.sbChecked = true;
|
||||
this.sectionBList = [];
|
||||
}
|
||||
},
|
||||
|
||||
/* ======= Actions ======= */
|
||||
async updateStatus(statusId, decision) {
|
||||
try {
|
||||
if (this.busy) return;
|
||||
this.busy = true; this.error = null;
|
||||
let comment = null;
|
||||
if (decision === 'Rejected') { const input = prompt('Optional rejection comment:'); comment = input?.trim() || null; }
|
||||
const res = await fetch(`/ItRequestAPI/approveReject`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ statusId, decision, comment })
|
||||
});
|
||||
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.message || `Failed (${res.status})`); }
|
||||
await this.loadApprovals();
|
||||
await this.loadSectionB();
|
||||
} catch (e) { this.error = e.message || 'Something went wrong.'; }
|
||||
finally { this.busy = false; }
|
||||
},
|
||||
async acceptIt(statusId) {
|
||||
try {
|
||||
if (this.busy) return;
|
||||
this.busy = true; this.error = null;
|
||||
const res = await fetch(`/ItRequestAPI/sectionB/accept`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ statusId, by: 'IT' })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.message || `IT accept failed (${res.status})`);
|
||||
}
|
||||
await this.loadSectionB();
|
||||
} catch (e) { this.error = e.message || 'Failed to accept as IT.'; }
|
||||
finally { this.busy = false; }
|
||||
},
|
||||
|
||||
async acceptRequestor(statusId) {
|
||||
try {
|
||||
if (this.busy) return;
|
||||
this.busy = true; this.error = null;
|
||||
const res = await fetch(`/ItRequestAPI/sectionB/accept`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ statusId, by: 'REQUESTOR' })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.message || `Accept failed (${res.status})`);
|
||||
}
|
||||
await this.loadSectionB();
|
||||
} catch (e) { this.error = e.message || 'Failed to accept as Requestor.'; }
|
||||
finally { this.busy = false; }
|
||||
},
|
||||
viewRequest(statusId) { window.location.href = `/IT/ApprovalDashboard/RequestReview?statusId=${statusId}`; },
|
||||
openSectionB(statusId) {
|
||||
const here = window.location.pathname + window.location.search + window.location.hash;
|
||||
const returnUrl = encodeURIComponent(here);
|
||||
window.location.href = `/IT/ApprovalDashboard/SectionB?statusId=${statusId}&returnUrl=${returnUrl}`;
|
||||
},
|
||||
openSectionBEdit(statusId) {
|
||||
const here = window.location.pathname + window.location.search + window.location.hash;
|
||||
const returnUrl = encodeURIComponent(here);
|
||||
window.location.href = `/IT/ApprovalDashboard/SectionBEdit?statusId=${statusId}&returnUrl=${returnUrl}`;
|
||||
},
|
||||
|
||||
downloadPdf(statusId) { window.open(`/ItRequestAPI/sectionB/pdf?statusId=${statusId}`, '_blank'); }
|
||||
},
|
||||
mounted() {
|
||||
// We need to call both to determine access flags.
|
||||
this.loadApprovals();
|
||||
this.loadSectionB();
|
||||
}
|
||||
});
|
||||
app.mount('#app');
|
||||
</script>
|
||||
825
Areas/IT/Views/ApprovalDashboard/Create.cshtml
Normal file
825
Areas/IT/Views/ApprovalDashboard/Create.cshtml
Normal file
@ -0,0 +1,825 @@
|
||||
@{
|
||||
ViewData["Title"] = "New IT Request";
|
||||
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||
}
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--card-r: 16px;
|
||||
--soft-b: #eef2f6;
|
||||
--soft-s: 0 8px 24px rgba(0,0,0,.08);
|
||||
--muted: #6b7280;
|
||||
--ok: #16a34a;
|
||||
--warn: #f59e0b;
|
||||
--err: #dc2626;
|
||||
}
|
||||
|
||||
#itFormApp {
|
||||
max-width: 1100px;
|
||||
margin: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin: 16px 0 10px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: .6rem;
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
|
||||
.subtle {
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ui-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--soft-b);
|
||||
border-radius: var(--card-r);
|
||||
box-shadow: var(--soft-s);
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ui-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--soft-b);
|
||||
background: linear-gradient(180deg,#fbfdff,#f7fafc);
|
||||
}
|
||||
|
||||
.ui-head h6 {
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
color: #0b5ed7;
|
||||
}
|
||||
|
||||
.ui-body {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.note {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .45rem;
|
||||
padding: .25rem .6rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
border: 1px solid rgba(0,0,0,.06);
|
||||
background: #eef2f7;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.chip i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chip-ok {
|
||||
background: #e7f7ed;
|
||||
color: #166534;
|
||||
border-color: #c7ecd3;
|
||||
}
|
||||
|
||||
.chip-warn {
|
||||
background: #fff7e6;
|
||||
color: #92400e;
|
||||
border-color: #fdebd1;
|
||||
}
|
||||
|
||||
.form-label .req {
|
||||
color: var(--err);
|
||||
margin-left: .2rem;
|
||||
}
|
||||
|
||||
.invalid-hint {
|
||||
color: var(--err);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.req-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2,1fr);
|
||||
gap: 12px 18px;
|
||||
}
|
||||
|
||||
@@media (max-width:768px) {
|
||||
.req-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.mini-table {
|
||||
border: 1px solid var(--soft-b);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mini-head {
|
||||
background: #f3f6fb;
|
||||
padding: 10px 12px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .4px;
|
||||
}
|
||||
|
||||
.mini-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.mini-row:hover {
|
||||
background: #fafcff;
|
||||
}
|
||||
|
||||
.btn-soft {
|
||||
border-radius: 10px;
|
||||
padding: .5rem .8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .2px;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background: #0b5ed7;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: #0a53be;
|
||||
}
|
||||
|
||||
.btn-del {
|
||||
background: #fff;
|
||||
color: #dc2626;
|
||||
border: 1px solid #f1d2d2;
|
||||
}
|
||||
|
||||
.btn-del:hover {
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.stacked-checks .form-check {
|
||||
margin-bottom: .4rem;
|
||||
}
|
||||
|
||||
.submit-bar {
|
||||
position: sticky;
|
||||
bottom: 12px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,.85);
|
||||
backdrop-filter: blur(6px);
|
||||
border: 1px solid var(--soft-b);
|
||||
box-shadow: var(--soft-s);
|
||||
margin: 6px 0 30px;
|
||||
}
|
||||
|
||||
.btn-go {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: .6rem .95rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.btn-go:hover {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
background: #0b5ed7;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: .6rem .95rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.btn-send:hover {
|
||||
background: #0a53be;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
border: 1px solid var(--soft-b);
|
||||
border-radius: 10px;
|
||||
padding: .6rem .95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-reset:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.perm-flags {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #eef2f6;
|
||||
margin: 8px 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="itFormApp">
|
||||
<div class="page-head">
|
||||
<h3 class="page-title"></h3>
|
||||
<div class="subtle">Stages: HOD → Group IT HOD → Finance HOD → Management</div>
|
||||
</div>
|
||||
|
||||
<!-- Requester -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-head">
|
||||
<h6><i class="bi bi-person-badge"></i> Requester Details</h6>
|
||||
<span class="note">These fields are snapshotted at submission</span>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div class="req-grid">
|
||||
<div>
|
||||
<label class="form-label">Staff Name</label>
|
||||
<input type="text" class="form-control" v-model.trim="model.staffName" readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Designation</label>
|
||||
<input type="text" class="form-control" v-model.trim="model.designation" readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Company</label>
|
||||
<input type="text" class="form-control" v-model.trim="model.companyName" readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Div/Dept</label>
|
||||
<input type="text" class="form-control" v-model.trim="model.departmentName" readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Location</label>
|
||||
<input type="text" class="form-control" v-model.trim="model.location" readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Phone Ext</label>
|
||||
<input type="text" class="form-control" v-model.trim="model.phoneExt" readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Employment Status</label>
|
||||
<select class="form-select" v-model="model.employmentStatus" disabled>
|
||||
<option value="">--</option>
|
||||
<option>Permanent</option>
|
||||
<option>Contract</option>
|
||||
<option>Temp</option>
|
||||
<option>New Staff</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="model.employmentStatus==='Contract' || model.employmentStatus==='Temp'">
|
||||
<label class="form-label">Contract End Date</label>
|
||||
<input type="date" class="form-control" v-model="model.contractEndDate" readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Required Date <span class="req">*</span></label>
|
||||
<input type="date" class="form-control" v-model="model.requiredDate" :min="minReqISO">
|
||||
<div class="invalid-hint" v-if="validation.requiredDate">{{ validation.requiredDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardware -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-head">
|
||||
<h6><i class="bi bi-cpu"></i> Hardware Requirements</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="chip" v-if="hardwareCount===0"><i class="bi bi-inboxes"></i>None selected</span>
|
||||
<span class="chip chip-ok" v-else><i class="bi bi-check2"></i>{{ hardwareCount }} selected</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Purpose <span class="req" v-if="hardwareCount>0">*</span></label>
|
||||
<select class="form-select" v-model="hardwarePurpose">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="NewRecruitment">New Staff Recruitment</option>
|
||||
<option value="Replacement">Replacement</option>
|
||||
<option value="Additional">Additional</option>
|
||||
</select>
|
||||
<div class="invalid-hint" v-if="validation.hardwarePurpose">{{ validation.hardwarePurpose }}</div>
|
||||
|
||||
<label class="form-label mt-3">Justification (for hardware change)</label>
|
||||
<textarea class="form-control" rows="3" v-model="hardwareJustification" placeholder="-"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<label class="form-label">Select below</label>
|
||||
<div class="small text-muted">All-inclusive toggles will auto-select sensible accessories</div>
|
||||
</div>
|
||||
<div class="stacked-checks">
|
||||
<div class="form-check" v-for="opt in hardwareCategories" :key="opt.key">
|
||||
<input class="form-check-input" type="checkbox" :id="'cat_'+opt.key"
|
||||
v-model="opt.include" @@change="onHardwareToggle(opt.key)">
|
||||
<label class="form-check-label" :for="'cat_'+opt.key">{{ opt.label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Other (Specify)</label>
|
||||
<input class="form-control form-control-sm" v-model.trim="hardwareOther" placeholder="-">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-head">
|
||||
<h6><i class="bi bi-envelope-paper"></i> Email</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="note">Enter proposed address(es) without <code>@@domain</code></span>
|
||||
<button class="btn-soft btn-add" @@click="addEmail"><i class="bi bi-plus"></i> Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div class="mini-table">
|
||||
<div class="mini-head">Proposed Address (without @@domain)</div>
|
||||
<div v-for="(row, i) in emailRows" :key="'em-'+i" class="mini-row">
|
||||
<div class="flex-grow-1">
|
||||
<input class="form-control form-control-sm" v-model.trim="row.proposedAddress" placeholder="e.g. j.doe">
|
||||
</div>
|
||||
<button class="btn btn-del btn-sm" @@click="removeEmail(i)"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
<div v-if="emailRows.length===0" class="mini-row">
|
||||
<div class="text-muted">No email rows</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OS -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-head">
|
||||
<h6><i class="bi bi-windows"></i> Operating System Requirements</h6>
|
||||
<button class="btn-soft btn-add" @@click="addOs"><i class="bi bi-plus"></i> Add</button>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div class="mini-table">
|
||||
<div class="mini-head">Requirement</div>
|
||||
<div v-for="(row, i) in osReqs" :key="'os-'+i" class="mini-row">
|
||||
<textarea class="form-control" rows="2" v-model="row.requirementText" placeholder="e.g. Windows 11 Pro required due to ..."></textarea>
|
||||
<button class="btn btn-del btn-sm" @@click="removeOs(i)"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
<div v-if="osReqs.length===0" class="mini-row">
|
||||
<div class="text-muted">No OS requirements</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Software -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-head">
|
||||
<h6><i class="bi bi-boxes"></i> Software</h6>
|
||||
<span class="note">Tick to include; use Others to specify anything not listed</span>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<h6 class="mb-2">General Software</h6>
|
||||
<div class="form-check" v-for="opt in softwareGeneralOpts" :key="'gen-'+opt">
|
||||
<input class="form-check-input" type="checkbox" :id="'gen_'+opt" v-model="softwareGeneral[opt]">
|
||||
<label class="form-check-label" :for="'gen_'+opt">{{ opt }}</label>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Others (Specify)</label>
|
||||
<input class="form-control form-control-sm" v-model.trim="softwareGeneralOther" placeholder="-">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<h6 class="mb-2">Utility Software</h6>
|
||||
<div class="form-check" v-for="opt in softwareUtilityOpts" :key="'utl-'+opt">
|
||||
<input class="form-check-input" type="checkbox" :id="'utl_'+opt" v-model="softwareUtility[opt]">
|
||||
<label class="form-check-label" :for="'utl_'+opt">{{ opt }}</label>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Others (Specify)</label>
|
||||
<input class="form-control form-control-sm" v-model.trim="softwareUtilityOther" placeholder="-">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<h6 class="mb-2">Custom Software</h6>
|
||||
<label class="form-label">Others (Specify)</label>
|
||||
<input class="form-control form-control-sm" v-model.trim="softwareCustomOther" placeholder="-">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared Permissions (CAP 6) -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-head">
|
||||
<h6><i class="bi bi-share"></i> Shared Permissions</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="note">Max 6 entries</span>
|
||||
<button class="btn-soft btn-add" @@click="addPerm" :disabled="sharedPerms.length>=6"><i class="bi bi-plus"></i> Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div class="mini-table">
|
||||
<div class="mini-head">Share Name & Permissions</div>
|
||||
<div v-for="(p, i) in sharedPerms" :key="'sp-'+i" class="mini-row">
|
||||
<input class="form-control form-control-sm" style="max-width:280px"
|
||||
v-model.trim="p.shareName" placeholder="e.g. Finance Shared Folder">
|
||||
<div class="perm-flags">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" v-model="p.canRead" :id="'r'+i">
|
||||
<label class="form-check-label" :for="'r'+i">Read</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" v-model="p.canWrite" :id="'w'+i">
|
||||
<label class="form-check-label" :for="'w'+i">Write</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" v-model="p.canDelete" :id="'d'+i">
|
||||
<label class="form-check-label" :for="'d'+i">Delete</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" v-model="p.canRemove" :id="'u'+i">
|
||||
<label class="form-check-label" :for="'u'+i">Remove</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-del btn-sm" @@click="removePerm(i)"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
<div v-if="sharedPerms.length===0" class="mini-row">
|
||||
<div class="text-muted">No shared permissions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="invalid-hint" v-if="validation.sharedPerms">{{ validation.sharedPerms }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit bar -->
|
||||
<div class="submit-bar">
|
||||
<div class="me-auto d-flex align-items-center gap-2">
|
||||
<span class="chip" :class="model.requiredDate ? 'chip-ok' : 'chip-warn'">
|
||||
<i class="bi" :class="model.requiredDate ? 'bi-check2' : 'bi-exclamation-triangle'"></i>
|
||||
{{ model.requiredDate ? 'Required date set' : 'Required date missing' }}
|
||||
</span>
|
||||
<span class="chip" v-if="hardwareCount>0 && !hardwarePurpose"><i class="bi bi-exclamation-triangle"></i> Hardware purpose required</span>
|
||||
<span class="chip chip-ok" v-else-if="hardwareCount>0"><i class="bi bi-check2"></i> Hardware purpose ok</span>
|
||||
<span class="chip" v-if="sharedPerms.length>6"><i class="bi bi-exclamation-triangle"></i> Max 6 permissions</span>
|
||||
</div>
|
||||
<button class="btn btn-reset" @@click="resetForm" :disabled="saving">Reset (sections)</button>
|
||||
<button class="btn btn-go" @@click="saveDraft" :disabled="saving">
|
||||
<span v-if="saving && intent==='draft'" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Save Draft
|
||||
</button>
|
||||
<button class="btn btn-send" @@click="openConfirm" :disabled="saving">
|
||||
<span v-if="saving && intent==='send'" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Send Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="sendConfirm" tabindex="-1" aria-labelledby="sendConfirmLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content" style="border-radius:14px;">
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title fw-bold" id="sendConfirmLabel">Submit & Lock</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
Please double-check your entries. Once sent, this request becomes <strong>Pending</strong> and is <strong>locked</strong> from editing.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="confirmSendBtn">Yes, Send Now</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
async function ensureBootstrapModal() {
|
||||
if (window.bootstrap && window.bootstrap.Modal) return;
|
||||
await new Promise((resolve) => {
|
||||
const s = document.createElement('script');
|
||||
s.src = "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js";
|
||||
s.async = true;
|
||||
s.onload = resolve;
|
||||
s.onerror = resolve;
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
const EDIT_WINDOW_HOURS = 24;
|
||||
|
||||
const app = Vue.createApp({
|
||||
data() {
|
||||
const plus7 = new Date(); plus7.setDate(plus7.getDate() + 7);
|
||||
return {
|
||||
saving: false,
|
||||
intent: '',
|
||||
validation: { requiredDate: "", hardwarePurpose: "", sharedPerms: "" },
|
||||
minReqISO: plus7.toISOString().slice(0, 10),
|
||||
|
||||
model: {
|
||||
userId: 0, staffName: "", companyName: "", departmentName: "",
|
||||
designation: "", location: "", employmentStatus: "", contractEndDate: null,
|
||||
requiredDate: "", phoneExt: ""
|
||||
},
|
||||
|
||||
hardwarePurpose: "",
|
||||
hardwareJustification: "",
|
||||
hardwareCategories: [
|
||||
{ key: "DesktopAllIn", label: "Desktop (all inclusive)", include: false },
|
||||
{ key: "NotebookAllIn", label: "Notebook (all inclusive)", include: false },
|
||||
{ key: "DesktopOnly", label: "Desktop only", include: false },
|
||||
{ key: "NotebookOnly", label: "Notebook only", include: false },
|
||||
{ key: "NotebookBattery", label: "Notebook battery", include: false },
|
||||
{ key: "PowerAdapter", label: "Power Adapter", include: false },
|
||||
{ key: "Mouse", label: "Computer Mouse", include: false },
|
||||
{ key: "ExternalHDD", label: "External Hard Drive", include: false }
|
||||
],
|
||||
hardwareOther: "",
|
||||
|
||||
emailRows: [],
|
||||
osReqs: [],
|
||||
|
||||
softwareGeneralOpts: ["MS Word", "MS Excel", "MS Outlook", "MS PowerPoint", "MS Access", "MS Project", "Acrobat Standard", "AutoCAD", "Worktop/ERP Login"],
|
||||
softwareUtilityOpts: ["PDF Viewer", "7Zip", "AutoCAD Viewer", "Smart Draw"],
|
||||
softwareGeneral: {},
|
||||
softwareUtility: {},
|
||||
softwareGeneralOther: "",
|
||||
softwareUtilityOther: "",
|
||||
softwareCustomOther: "",
|
||||
|
||||
// shared permissions
|
||||
sharedPerms: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hardwareCount() {
|
||||
let c = this.hardwareCategories.filter(x => x.include).length;
|
||||
if (this.hardwareOther.trim()) c += 1;
|
||||
return c;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// ----- Hardware helpers -----
|
||||
onHardwareToggle(key) {
|
||||
const set = (k, v) => {
|
||||
const t = this.hardwareCategories.find(x => x.key === k);
|
||||
if (t) t.include = v;
|
||||
};
|
||||
if (key === "DesktopAllIn") {
|
||||
const allIn = this.hardwareCategories.find(x => x.key === "DesktopAllIn")?.include;
|
||||
if (allIn) {
|
||||
// mutually exclusive with NotebookOnly/NotebookAllIn
|
||||
set("NotebookAllIn", false);
|
||||
set("NotebookOnly", false);
|
||||
// sensible accessories
|
||||
set("Mouse", true);
|
||||
// desktop doesn't need PowerAdapter
|
||||
}
|
||||
}
|
||||
if (key === "NotebookAllIn") {
|
||||
const allIn = this.hardwareCategories.find(x => x.key === "NotebookAllIn")?.include;
|
||||
if (allIn) {
|
||||
set("DesktopAllIn", false);
|
||||
set("DesktopOnly", false);
|
||||
// sensible accessories
|
||||
set("PowerAdapter", true);
|
||||
set("Mouse", true);
|
||||
set("NotebookBattery", true);
|
||||
}
|
||||
}
|
||||
if (key === "DesktopOnly") {
|
||||
const only = this.hardwareCategories.find(x => x.key === "DesktopOnly")?.include;
|
||||
if (only) {
|
||||
set("DesktopAllIn", false);
|
||||
}
|
||||
}
|
||||
if (key === "NotebookOnly") {
|
||||
const only = this.hardwareCategories.find(x => x.key === "NotebookOnly")?.include;
|
||||
if (only) {
|
||||
set("NotebookAllIn", false);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ----- Email/OS -----
|
||||
addEmail() { this.emailRows.push({ proposedAddress: "" }); },
|
||||
removeEmail(i) { this.emailRows.splice(i, 1); },
|
||||
addOs() { this.osReqs.push({ requirementText: "" }); },
|
||||
removeOs(i) { this.osReqs.splice(i, 1); },
|
||||
|
||||
// ----- Shared perms -----
|
||||
addPerm() {
|
||||
if (this.sharedPerms.length >= 6) return;
|
||||
this.sharedPerms.push({ shareName: "", canRead: true, canWrite: false, canDelete: false, canRemove: false });
|
||||
},
|
||||
removePerm(i) { this.sharedPerms.splice(i, 1); },
|
||||
|
||||
// ----- Validation -----
|
||||
validate() {
|
||||
this.validation = { requiredDate: "", hardwarePurpose: "", sharedPerms: "" };
|
||||
|
||||
if (!this.model.requiredDate) {
|
||||
this.validation.requiredDate = "Required Date is mandatory.";
|
||||
} else if (this.model.requiredDate < this.minReqISO) {
|
||||
this.validation.requiredDate = "Required Date must be at least 7 days from today.";
|
||||
}
|
||||
|
||||
const anyHardware = this.hardwareCount > 0;
|
||||
if (anyHardware && !this.hardwarePurpose) this.validation.hardwarePurpose = "Please select a Hardware Purpose.";
|
||||
|
||||
if (this.sharedPerms.length > 6) this.validation.sharedPerms = "Maximum 6 shared permissions.";
|
||||
|
||||
return !this.validation.requiredDate && !this.validation.hardwarePurpose && !this.validation.sharedPerms;
|
||||
},
|
||||
|
||||
// ----- DTO -----
|
||||
buildDto() {
|
||||
const hardware = [];
|
||||
const justification = this.hardwareJustification || "";
|
||||
const purpose = this.hardwarePurpose || "";
|
||||
this.hardwareCategories.forEach(c => {
|
||||
if (c.include) hardware.push({ category: c.key, purpose, justification, otherDescription: "" });
|
||||
});
|
||||
if (this.hardwareOther.trim()) {
|
||||
hardware.push({ category: "Other", purpose, justification, otherDescription: this.hardwareOther.trim() });
|
||||
}
|
||||
|
||||
const emails = this.emailRows.map(x => ({ proposedAddress: x.proposedAddress || "" }));
|
||||
const OSReqs = this.osReqs.map(x => ({ requirementText: x.requirementText }));
|
||||
|
||||
const software = [];
|
||||
Object.keys(this.softwareGeneral).forEach(name => { if (this.softwareGeneral[name]) software.push({ bucket: "General", name, otherName: "", notes: "" }); });
|
||||
Object.keys(this.softwareUtility).forEach(name => { if (this.softwareUtility[name]) software.push({ bucket: "Utility", name, otherName: "", notes: "" }); });
|
||||
if (this.softwareGeneralOther?.trim()) software.push({ bucket: "General", name: "Others", otherName: this.softwareGeneralOther.trim(), notes: "" });
|
||||
if (this.softwareUtilityOther?.trim()) software.push({ bucket: "Utility", name: "Others", otherName: this.softwareUtilityOther.trim(), notes: "" });
|
||||
if (this.softwareCustomOther?.trim()) software.push({ bucket: "Custom", name: "Others", otherName: this.softwareCustomOther.trim(), notes: "" });
|
||||
|
||||
// shared perms (cap at 6 client-side)
|
||||
const sharedPerms = this.sharedPerms.slice(0, 6).map(x => ({
|
||||
shareName: x.shareName || "",
|
||||
canRead: !!x.canRead,
|
||||
canWrite: !!x.canWrite,
|
||||
canDelete: !!x.canDelete,
|
||||
canRemove: !!x.canRemove
|
||||
}));
|
||||
|
||||
return {
|
||||
staffName: this.model.staffName,
|
||||
companyName: this.model.companyName,
|
||||
departmentName: this.model.departmentName,
|
||||
designation: this.model.designation,
|
||||
location: this.model.location,
|
||||
employmentStatus: this.model.employmentStatus,
|
||||
contractEndDate: this.model.contractEndDate || null,
|
||||
requiredDate: this.model.requiredDate,
|
||||
phoneExt: this.model.phoneExt,
|
||||
editWindowHours: EDIT_WINDOW_HOURS,
|
||||
hardware, emails, OSReqs, software, sharedPerms
|
||||
};
|
||||
},
|
||||
|
||||
async createRequest(sendNow = false) {
|
||||
const dto = this.buildDto();
|
||||
dto.sendNow = !!sendNow;
|
||||
const r = await fetch('/ItRequestAPI/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(dto)
|
||||
});
|
||||
const ct = r.headers.get('content-type') || '';
|
||||
const payload = ct.includes('application/json') ? await r.json() : { message: await r.text() };
|
||||
if (!r.ok) throw new Error(payload?.message || `Create failed (${r.status})`);
|
||||
const statusId = payload?.statusId;
|
||||
if (!statusId) throw new Error('Create succeeded but no statusId returned.');
|
||||
return statusId;
|
||||
},
|
||||
|
||||
async saveDraft() {
|
||||
if (!this.validate()) return;
|
||||
this.saving = true; this.intent = 'draft';
|
||||
try {
|
||||
await this.createRequest(false);
|
||||
window.location.href = `/IT/ApprovalDashboard/MyRequests`;
|
||||
} catch (e) {
|
||||
alert('Error: ' + (e?.message || e));
|
||||
} finally { this.saving = false; this.intent = ''; }
|
||||
},
|
||||
|
||||
async openConfirm() {
|
||||
if (!this.validate()) return;
|
||||
await ensureBootstrapModal();
|
||||
const modalEl = document.getElementById('sendConfirm');
|
||||
if (!window.bootstrap || !bootstrap.Modal) {
|
||||
if (confirm('Submit & Lock?\nOnce sent, this request becomes Pending and is locked from editing.')) {
|
||||
this.sendNow();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const Modal = bootstrap.Modal;
|
||||
const inst = (typeof Modal.getOrCreateInstance === 'function')
|
||||
? Modal.getOrCreateInstance(modalEl)
|
||||
: (Modal.getInstance(modalEl) || new Modal(modalEl));
|
||||
const btn = document.getElementById('confirmSendBtn');
|
||||
btn.onclick = () => { inst.hide(); this.sendNow(); };
|
||||
inst.show();
|
||||
},
|
||||
|
||||
async sendNow() {
|
||||
if (!this.validate()) return;
|
||||
this.saving = true; this.intent = 'send';
|
||||
try {
|
||||
await this.createRequest(true);
|
||||
window.location.href = `/IT/ApprovalDashboard/MyRequests`;
|
||||
} catch (e) {
|
||||
alert('Error: ' + (e?.message || e));
|
||||
} finally {
|
||||
this.saving = false; this.intent = '';
|
||||
}
|
||||
},
|
||||
|
||||
resetForm() {
|
||||
this.hardwarePurpose = "";
|
||||
this.hardwareJustification = "";
|
||||
this.hardwareCategories.forEach(x => x.include = false);
|
||||
this.hardwareOther = "";
|
||||
this.emailRows = [];
|
||||
this.osReqs = [];
|
||||
this.softwareGeneral = {};
|
||||
this.softwareUtility = {};
|
||||
this.softwareGeneralOther = "";
|
||||
this.softwareUtilityOther = "";
|
||||
this.softwareCustomOther = "";
|
||||
this.sharedPerms = [];
|
||||
this.model.requiredDate = "";
|
||||
this.validation = { requiredDate: "", hardwarePurpose: "", sharedPerms: "" };
|
||||
},
|
||||
|
||||
async prefillFromServer() {
|
||||
try {
|
||||
const res = await fetch('/ItRequestAPI/me');
|
||||
if (!res.ok) return;
|
||||
const me = await res.json();
|
||||
this.model.userId = me.userId || 0;
|
||||
this.model.staffName = me.staffName || "";
|
||||
this.model.companyName = me.companyName || "";
|
||||
this.model.departmentName = me.departmentName || "";
|
||||
this.model.designation = me.designation || "";
|
||||
this.model.location = me.location || "";
|
||||
this.model.employmentStatus = me.employmentStatus || "";
|
||||
this.model.contractEndDate = me.contractEndDate || null;
|
||||
this.model.phoneExt = me.phoneExt || "";
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
},
|
||||
mounted() { this.prefillFromServer(); }
|
||||
});
|
||||
app.mount('#itFormApp');
|
||||
</script>
|
||||
}
|
||||
698
Areas/IT/Views/ApprovalDashboard/Edit.cshtml
Normal file
698
Areas/IT/Views/ApprovalDashboard/Edit.cshtml
Normal file
@ -0,0 +1,698 @@
|
||||
@{
|
||||
ViewData["Title"] = "Edit IT Request";
|
||||
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||
}
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--card-r: 16px;
|
||||
--soft-b: #eef2f6;
|
||||
--soft-s: 0 8px 24px rgba(0,0,0,.08);
|
||||
--muted: #6b7280;
|
||||
}
|
||||
|
||||
#editApp {
|
||||
max-width: 1100px;
|
||||
margin: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin: 16px 0 10px;
|
||||
}
|
||||
|
||||
.subtle {
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ui-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--soft-b);
|
||||
border-radius: var(--card-r);
|
||||
box-shadow: var(--soft-s);
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ui-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid var(--soft-b);
|
||||
background: linear-gradient(180deg,#fbfdff,#f7fafc);
|
||||
}
|
||||
|
||||
.ui-head h6 {
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
color: #0b5ed7;
|
||||
}
|
||||
|
||||
.ui-body {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.note {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mini-table {
|
||||
border: 1px solid var(--soft-b);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mini-head {
|
||||
background: #f3f6fb;
|
||||
padding: 10px 12px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .4px;
|
||||
}
|
||||
|
||||
.mini-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.mini-row:hover {
|
||||
background: #fafcff;
|
||||
}
|
||||
|
||||
.btn-soft {
|
||||
border-radius: 10px;
|
||||
padding: .5rem .8rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: .2px;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
.btn-add {
|
||||
background: #0b5ed7;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-add:hover {
|
||||
background: #0a53be;
|
||||
}
|
||||
|
||||
.btn-del {
|
||||
background: #fff;
|
||||
color: #dc2626;
|
||||
border: 1px solid #f1d2d2;
|
||||
}
|
||||
|
||||
.btn-del:hover {
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.stacked-checks .form-check {
|
||||
margin-bottom: .4rem;
|
||||
}
|
||||
|
||||
.submit-bar {
|
||||
position: sticky;
|
||||
bottom: 12px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,.85);
|
||||
backdrop-filter: blur(6px);
|
||||
border: 1px solid var(--soft-b);
|
||||
box-shadow: var(--soft-s);
|
||||
margin: 6px 0 30px;
|
||||
}
|
||||
|
||||
.btn-go {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: .6rem .95rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.btn-go:hover {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
border: 1px solid var(--soft-b);
|
||||
border-radius: 10px;
|
||||
padding: .6rem .95rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.btn-reset:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.invalid-hint {
|
||||
color: #dc2626;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.countdown-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
border-radius: 8px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.05);
|
||||
min-width: 80px;
|
||||
justify-content: center;
|
||||
transition: background .3s ease, color .3s ease;
|
||||
}
|
||||
|
||||
.countdown-active {
|
||||
background: #ecfdf5;
|
||||
color: #166534;
|
||||
border-color: #bbf7d0;
|
||||
}
|
||||
|
||||
.countdown-expired {
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.perm-flags {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="editApp">
|
||||
<div class="page-head">
|
||||
<div class="subtle d-flex align-items-center gap-2">
|
||||
<span class="badge" :class="isEditable ? 'bg-success' : 'bg-secondary'">{{ isEditable ? 'Editable' : 'Locked' }}</span>
|
||||
<div class="countdown-box" :class="isEditable ? 'countdown-active' : 'countdown-expired'">
|
||||
<i class="bi bi-clock-history me-1"></i>
|
||||
<span id="countdown">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requester -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-head">
|
||||
<h6><i class="bi bi-person-badge"></i> Requester Details</h6>
|
||||
<span class="note">These fields are snapshotted at submission</span>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div class="req-grid">
|
||||
<div>
|
||||
<label class="form-label">Staff Name</label>
|
||||
<input type="text" class="form-control" v-model.trim="model.staffName" readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Designation</label>
|
||||
<input type="text" class="form-control" v-model.trim="model.designation" :disabled="!isEditable">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Company</label>
|
||||
<input type="text" class="form-control" v-model.trim="model.companyName" readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Div/Dept</label>
|
||||
<input type="text" class="form-control" v-model.trim="model.departmentName" readonly>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Location</label>
|
||||
<input type="text" class="form-control" v-model.trim="model.location" :disabled="!isEditable">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Phone Ext</label>
|
||||
<input type="text" class="form-control" v-model.trim="model.phoneExt" :disabled="!isEditable">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Employment Status</label>
|
||||
<select class="form-select" v-model="model.employmentStatus" :disabled="!isEditable">
|
||||
<option value="">--</option>
|
||||
<option>Permanent</option>
|
||||
<option>Contract</option>
|
||||
<option>Temp</option>
|
||||
<option>New Staff</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="model.employmentStatus==='Contract' || model.employmentStatus==='Temp'">
|
||||
<label class="form-label">Contract End Date</label>
|
||||
<input type="date" class="form-control" v-model="model.contractEndDate" :disabled="!isEditable">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Required Date <span class="text-danger">*</span></label>
|
||||
<input type="date" class="form-control" v-model="model.requiredDate" :min="minReqISO" :disabled="!isEditable">
|
||||
<div class="invalid-hint" v-if="validation.requiredDate">{{ validation.requiredDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardware -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-head">
|
||||
<h6><i class="bi bi-cpu"></i> Hardware Requirements</h6>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Purpose <span class="text-danger" v-if="hardwareCount>0">*</span></label>
|
||||
<select class="form-select" v-model="hardwarePurpose" :disabled="!isEditable">
|
||||
<option value="">-- Select --</option>
|
||||
<option value="NewRecruitment">New Staff Recruitment</option>
|
||||
<option value="Replacement">Replacement</option>
|
||||
<option value="Additional">Additional</option>
|
||||
</select>
|
||||
<div class="invalid-hint" v-if="validation.hardwarePurpose">{{ validation.hardwarePurpose }}</div>
|
||||
|
||||
<label class="form-label mt-3">Justification (for hardware change)</label>
|
||||
<textarea class="form-control" rows="3" v-model="hardwareJustification" :disabled="!isEditable" placeholder="-"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<label class="form-label">Select below</label>
|
||||
<div class="small text-muted">All-inclusive toggles will auto-select sensible accessories</div>
|
||||
</div>
|
||||
<div class="stacked-checks">
|
||||
<div class="form-check" v-for="opt in hardwareCategories" :key="opt.key">
|
||||
<input class="form-check-input" type="checkbox" :id="'cat_'+opt.key"
|
||||
v-model="opt.include" :disabled="!isEditable" @@change="onHardwareToggle(opt.key)">
|
||||
<label class="form-check-label" :for="'cat_'+opt.key">{{ opt.label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Other (Specify)</label>
|
||||
<input class="form-control form-control-sm" v-model.trim="hardwareOther" :disabled="!isEditable" placeholder="-">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-head">
|
||||
<h6><i class="bi bi-envelope-paper"></i> Email</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="note">Enter proposed address(es) without <code>@@domain</code></span>
|
||||
<button class="btn-soft btn-add" @@click="addEmail" :disabled="!isEditable"><i class="bi bi-plus"></i> Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div class="mini-table">
|
||||
<div class="mini-head">Proposed Address (without @@domain)</div>
|
||||
<div v-for="(row, i) in emailRows" :key="'em-'+i" class="mini-row">
|
||||
<div class="flex-grow-1">
|
||||
<input class="form-control form-control-sm" v-model.trim="row.proposedAddress" :disabled="!isEditable" placeholder="e.g. j.doe">
|
||||
</div>
|
||||
<button class="btn btn-del btn-sm" @@click="removeEmail(i)" :disabled="!isEditable"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
<div v-if="emailRows.length===0" class="mini-row">
|
||||
<div class="text-muted">No email rows</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OS -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-head">
|
||||
<h6><i class="bi bi-windows"></i> Operating System Requirements</h6>
|
||||
<button class="btn-soft btn-add" @@click="addOs" :disabled="!isEditable"><i class="bi bi-plus"></i> Add</button>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div class="mini-table">
|
||||
<div class="mini-head">Requirement</div>
|
||||
<div v-for="(row, i) in osReqs" :key="'os-'+i" class="mini-row">
|
||||
<textarea class="form-control" rows="2" v-model="row.requirementText" :disabled="!isEditable" placeholder="e.g. Windows 11 Pro required due to ..."></textarea>
|
||||
<button class="btn btn-del btn-sm" @@click="removeOs(i)" :disabled="!isEditable"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
<div v-if="osReqs.length===0" class="mini-row">
|
||||
<div class="text-muted">No OS requirements</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Software -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-head">
|
||||
<h6><i class="bi bi-boxes"></i> Software</h6>
|
||||
<span class="note">Tick to include; use Others to specify anything not listed</span>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<h6 class="mb-2">General Software</h6>
|
||||
<div class="form-check" v-for="opt in softwareGeneralOpts" :key="'gen-'+opt">
|
||||
<input class="form-check-input" type="checkbox" :id="'gen_'+opt" v-model="softwareGeneral[opt]" :disabled="!isEditable">
|
||||
<label class="form-check-label" :for="'gen_'+opt">{{ opt }}</label>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Others (Specify)</label>
|
||||
<input class="form-control form-control-sm" v-model.trim="softwareGeneralOther" :disabled="!isEditable" placeholder="-">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<h6 class="mb-2">Utility Software</h6>
|
||||
<div class="form-check" v-for="opt in softwareUtilityOpts" :key="'utl-'+opt">
|
||||
<input class="form-check-input" type="checkbox" :id="'utl_'+opt" v-model="softwareUtility[opt]" :disabled="!isEditable">
|
||||
<label class="form-check-label" :for="'utl_'+opt">{{ opt }}</label>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Others (Specify)</label>
|
||||
<input class="form-control form-control-sm" v-model.trim="softwareUtilityOther" :disabled="!isEditable" placeholder="-">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<h6 class="mb-2">Custom Software</h6>
|
||||
<label class="form-label">Others (Specify)</label>
|
||||
<input class="form-control form-control-sm" v-model.trim="softwareCustomOther" :disabled="!isEditable" placeholder="-">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared Permissions (CAP 6) -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-head">
|
||||
<h6><i class="bi bi-share"></i> Shared Permissions</h6>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="note">Max 6 entries</span>
|
||||
<button class="btn-soft btn-add" @@click="addPerm" :disabled="!isEditable || sharedPerms.length>=6"><i class="bi bi-plus"></i> Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div class="mini-table">
|
||||
<div class="mini-head">Share Name & Permissions</div>
|
||||
<div v-for="(p, i) in sharedPerms" :key="'sp-'+i" class="mini-row">
|
||||
<input class="form-control form-control-sm" style="max-width:280px"
|
||||
v-model.trim="p.shareName" :disabled="!isEditable" placeholder="e.g. Finance Shared Folder">
|
||||
<div class="perm-flags">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" v-model="p.canRead" :id="'r'+i" :disabled="!isEditable">
|
||||
<label class="form-check-label" :for="'r'+i">Read</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" v-model="p.canWrite" :id="'w'+i" :disabled="!isEditable">
|
||||
<label class="form-check-label" :for="'w'+i">Write</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" v-model="p.canDelete" :id="'d'+i" :disabled="!isEditable">
|
||||
<label class="form-check-label" :for="'d'+i">Delete</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="checkbox" v-model="p.canRemove" :id="'u'+i" :disabled="!isEditable">
|
||||
<label class="form-check-label" :for="'u'+i">Remove User</label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-del btn-sm" @@click="removePerm(i)" :disabled="!isEditable"><i class="bi bi-x"></i></button>
|
||||
</div>
|
||||
<div v-if="sharedPerms.length===0" class="mini-row">
|
||||
<div class="text-muted">No shared permissions</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="invalid-hint" v-if="validation.sharedPerms">{{ validation.sharedPerms }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky Submit Bar -->
|
||||
<div class="submit-bar">
|
||||
<button class="btn btn-reset" @@click="reload" :disabled="busy">Reload</button>
|
||||
<button class="btn btn-primary" @@click="save" :disabled="busy || !isEditable">
|
||||
<span v-if="busy" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Save Draft
|
||||
</button>
|
||||
<button class="btn btn-go" @@click="sendNow" :disabled="busy || !isEditable">Send now</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
const statusId = new URLSearchParams(location.search).get('statusId');
|
||||
|
||||
const app = Vue.createApp({
|
||||
data() {
|
||||
const plus7 = new Date(); plus7.setDate(plus7.getDate() + 7);
|
||||
return {
|
||||
busy: false,
|
||||
isEditable: false,
|
||||
remaining: 0,
|
||||
minReqISO: plus7.toISOString().slice(0, 10),
|
||||
validation: { requiredDate: "", hardwarePurpose: "", sharedPerms: "" },
|
||||
|
||||
model: {
|
||||
userId: 0, staffName: "", companyName: "", departmentName: "",
|
||||
designation: "", location: "", employmentStatus: "", contractEndDate: null,
|
||||
requiredDate: "", phoneExt: ""
|
||||
},
|
||||
|
||||
hardwarePurpose: "",
|
||||
hardwareJustification: "",
|
||||
hardwareCategories: [
|
||||
{ key: "DesktopAllIn", label: "Desktop (all inclusive)", include: false },
|
||||
{ key: "NotebookAllIn", label: "Notebook (all inclusive)", include: false },
|
||||
{ key: "DesktopOnly", label: "Desktop only", include: false },
|
||||
{ key: "NotebookOnly", label: "Notebook only", include: false },
|
||||
{ key: "NotebookBattery", label: "Notebook battery", include: false },
|
||||
{ key: "PowerAdapter", label: "Power Adapter", include: false },
|
||||
{ key: "Mouse", label: "Computer Mouse", include: false },
|
||||
{ key: "ExternalHDD", label: "External Hard Drive", include: false }
|
||||
],
|
||||
hardwareOther: "",
|
||||
|
||||
emailRows: [],
|
||||
osReqs: [],
|
||||
|
||||
softwareGeneralOpts: ["MS Word", "MS Excel", "MS Outlook", "MS PowerPoint", "MS Access", "MS Project", "Acrobat Standard", "AutoCAD", "Worktop/ERP Login"],
|
||||
softwareUtilityOpts: ["PDF Viewer", "7Zip", "AutoCAD Viewer", "Smart Draw"],
|
||||
softwareGeneral: {},
|
||||
softwareUtility: {},
|
||||
softwareGeneralOther: "",
|
||||
softwareUtilityOther: "",
|
||||
softwareCustomOther: "",
|
||||
|
||||
// shared perms
|
||||
sharedPerms: []
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hardwareCount() {
|
||||
let c = this.hardwareCategories.filter(x => x.include).length;
|
||||
if (this.hardwareOther.trim()) c += 1;
|
||||
return c;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// timers
|
||||
startCountdown() {
|
||||
const el = document.getElementById('countdown');
|
||||
if (!el) return;
|
||||
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
|
||||
const tick = () => {
|
||||
if (this.remaining <= 0) { el.textContent = '0s'; this.isEditable = false; return; }
|
||||
const h = Math.floor(this.remaining / 3600);
|
||||
const m = Math.floor((this.remaining % 3600) / 60);
|
||||
const s = this.remaining % 60;
|
||||
el.textContent = (h ? `${h}h ` : '') + (m ? `${m}m ` : (h ? '0m ' : '')) + `${s}s`;
|
||||
this.remaining--; this._timer = setTimeout(tick, 1000);
|
||||
}; this.$nextTick(tick);
|
||||
},
|
||||
startSyncRemaining() {
|
||||
if (this._syncTimer) { clearInterval(this._syncTimer); this._syncTimer = null; }
|
||||
const doSync = async () => {
|
||||
try {
|
||||
const r = await fetch(`/ItRequestAPI/editWindow/${statusId}`);
|
||||
if (!r.ok) return;
|
||||
const j = await r.json();
|
||||
const srvRemaining = (j && typeof j.remainingSeconds === 'number') ? j.remainingSeconds : null;
|
||||
if (srvRemaining != null) { this.remaining = srvRemaining; this.isEditable = !!(j.isEditable); }
|
||||
if (!this.isEditable || this.remaining <= 0) {
|
||||
if (this._timer) { clearTimeout(this._timer); this._timer = null; }
|
||||
if (this._syncTimer) { clearInterval(this._syncTimer); this._syncTimer = null; }
|
||||
const el = document.getElementById('countdown'); if (el) el.textContent = '0s';
|
||||
}
|
||||
} catch { }
|
||||
};
|
||||
doSync(); this._syncTimer = setInterval(doSync, 30000);
|
||||
},
|
||||
teardownTimers() { if (this._timer) clearTimeout(this._timer); if (this._syncTimer) clearInterval(this._syncTimer); },
|
||||
|
||||
// UI helpers
|
||||
addEmail() { if (!this.isEditable) return; this.emailRows.push({ proposedAddress: "" }); },
|
||||
removeEmail(i) { if (!this.isEditable) return; this.emailRows.splice(i, 1); },
|
||||
addOs() { if (!this.isEditable) return; this.osReqs.push({ requirementText: "" }); },
|
||||
removeOs(i) { if (!this.isEditable) return; this.osReqs.splice(i, 1); },
|
||||
|
||||
addPerm() { if (!this.isEditable) return; if (this.sharedPerms.length >= 6) return; this.sharedPerms.push({ shareName: "", canRead: true, canWrite: false, canDelete: false, canRemove: false }); },
|
||||
removePerm(i) { if (!this.isEditable) return; this.sharedPerms.splice(i, 1); },
|
||||
|
||||
onHardwareToggle(key) {
|
||||
if (!this.isEditable) return;
|
||||
const set = (k, v) => { const t = this.hardwareCategories.find(x => x.key === k); if (t) t.include = v; };
|
||||
if (key === "DesktopAllIn") {
|
||||
const on = this.hardwareCategories.find(x => x.key === "DesktopAllIn")?.include;
|
||||
if (on) { set("NotebookAllIn", false); set("NotebookOnly", false); set("Mouse", true); }
|
||||
}
|
||||
if (key === "NotebookAllIn") {
|
||||
const on = this.hardwareCategories.find(x => x.key === "NotebookAllIn")?.include;
|
||||
if (on) { set("DesktopAllIn", false); set("DesktopOnly", false); set("PowerAdapter", true); set("Mouse", true); set("NotebookBattery", true); }
|
||||
}
|
||||
if (key === "DesktopOnly") { const on = this.hardwareCategories.find(x => x.key === "DesktopOnly")?.include; if (on) { set("DesktopAllIn", false); } }
|
||||
if (key === "NotebookOnly") { const on = this.hardwareCategories.find(x => x.key === "NotebookOnly")?.include; if (on) { set("NotebookAllIn", false); } }
|
||||
},
|
||||
|
||||
// validation
|
||||
validate() {
|
||||
this.validation = { requiredDate: "", hardwarePurpose: "", sharedPerms: "" };
|
||||
if (!this.model.requiredDate) this.validation.requiredDate = "Required Date is mandatory.";
|
||||
else if (this.model.requiredDate < this.minReqISO) this.validation.requiredDate = "Required Date must be at least 7 days from today.";
|
||||
if (this.hardwareCount > 0 && !this.hardwarePurpose) this.validation.hardwarePurpose = "Please select a Hardware Purpose.";
|
||||
if (this.sharedPerms.length > 6) this.validation.sharedPerms = "Maximum 6 shared permissions.";
|
||||
return !this.validation.requiredDate && !this.validation.hardwarePurpose && !this.validation.sharedPerms;
|
||||
},
|
||||
|
||||
// dto
|
||||
buildDto() {
|
||||
const hardware = [];
|
||||
const justification = this.hardwareJustification || "";
|
||||
const purpose = this.hardwarePurpose || "";
|
||||
this.hardwareCategories.forEach(c => { if (c.include) hardware.push({ category: c.key, purpose, justification, otherDescription: "" }); });
|
||||
if (this.hardwareOther.trim()) { hardware.push({ category: "Other", purpose, justification, otherDescription: this.hardwareOther.trim() }); }
|
||||
const emails = this.emailRows.map(x => ({ proposedAddress: x.proposedAddress || "" }));
|
||||
const oSReqs = this.osReqs.map(x => ({ requirementText: x.requirementText }));
|
||||
const software = [];
|
||||
Object.keys(this.softwareGeneral).forEach(n => { if (this.softwareGeneral[n]) software.push({ bucket: "General", name: n, otherName: "", notes: "" }); });
|
||||
Object.keys(this.softwareUtility).forEach(n => { if (this.softwareUtility[n]) software.push({ bucket: "Utility", name: n, otherName: "", notes: "" }); });
|
||||
if (this.softwareGeneralOther?.trim()) software.push({ bucket: "General", name: "Others", otherName: this.softwareGeneralOther.trim(), notes: "" });
|
||||
if (this.softwareUtilityOther?.trim()) software.push({ bucket: "Utility", name: "Others", otherName: this.softwareUtilityOther.trim(), notes: "" });
|
||||
if (this.softwareCustomOther?.trim()) software.push({ bucket: "Custom", name: "Others", otherName: this.softwareCustomOther.trim(), notes: "" });
|
||||
|
||||
const sharedPerms = this.sharedPerms.slice(0, 6).map(x => ({
|
||||
shareName: x.shareName || "", canRead: !!x.canRead, canWrite: !!x.canWrite, canDelete: !!x.canDelete, canRemove: !!x.canRemove
|
||||
}));
|
||||
|
||||
return {
|
||||
staffName: this.model.staffName, companyName: this.model.companyName, departmentName: this.model.departmentName,
|
||||
designation: this.model.designation, location: this.model.location, employmentStatus: this.model.employmentStatus,
|
||||
contractEndDate: this.model.contractEndDate || null, requiredDate: this.model.requiredDate, phoneExt: this.model.phoneExt,
|
||||
hardware, emails, oSReqs, software, sharedPerms
|
||||
};
|
||||
},
|
||||
|
||||
async load() {
|
||||
try {
|
||||
this.busy = true;
|
||||
const r = await fetch(`/ItRequestAPI/request/${statusId}`); if (!r.ok) throw new Error('Failed to load request');
|
||||
const j = await r.json();
|
||||
|
||||
const req = j.request || {};
|
||||
this.model.staffName = req.staffName || ""; this.model.companyName = req.companyName || "";
|
||||
this.model.departmentName = req.departmentName || ""; this.model.designation = req.designation || "";
|
||||
this.model.location = req.location || ""; this.model.employmentStatus = req.employmentStatus || "";
|
||||
this.model.contractEndDate = req.contractEndDate || null;
|
||||
this.model.requiredDate = req.requiredDate ? req.requiredDate.substring(0, 10) : "";
|
||||
this.model.phoneExt = req.phoneExt || "";
|
||||
|
||||
this.hardwarePurpose = ""; this.hardwareJustification = ""; this.hardwareOther = "";
|
||||
this.hardwareCategories.forEach(x => x.include = false);
|
||||
(j.hardware || []).forEach(h => {
|
||||
if (h.purpose && !this.hardwarePurpose) this.hardwarePurpose = h.purpose;
|
||||
if (h.justification && !this.hardwareJustification) this.hardwareJustification = h.justification;
|
||||
if (h.category === "Other") { if (h.otherDescription) this.hardwareOther = h.otherDescription; }
|
||||
else { const t = this.hardwareCategories.find(c => c.key === h.category); if (t) t.include = true; }
|
||||
});
|
||||
|
||||
this.emailRows = (j.emails || []).map(e => ({ proposedAddress: e.proposedAddress || "" }));
|
||||
this.osReqs = (j.osreqs || []).map(o => ({ requirementText: o.requirementText || "" }));
|
||||
|
||||
this.softwareGeneral = {}; this.softwareUtility = {};
|
||||
this.softwareGeneralOther = ""; this.softwareUtilityOther = ""; this.softwareCustomOther = "";
|
||||
(j.software || []).forEach(sw => {
|
||||
if (sw.bucket === "General" && sw.name !== "Others") this.softwareGeneral[sw.name] = true;
|
||||
else if (sw.bucket === "Utility" && sw.name !== "Others") this.softwareUtility[sw.name] = true;
|
||||
else if (sw.bucket === "General" && sw.name === "Others") this.softwareGeneralOther = sw.otherName || "";
|
||||
else if (sw.bucket === "Utility" && sw.name === "Others") this.softwareUtilityOther = sw.otherName || "";
|
||||
else if (sw.bucket === "Custom") this.softwareCustomOther = sw.otherName || "";
|
||||
});
|
||||
|
||||
// Shared perms from API (if present)
|
||||
this.sharedPerms = (j.sharedPerms || []).map(sp => ({
|
||||
shareName: sp.shareName || "",
|
||||
canRead: !!sp.canRead, canWrite: !!sp.canWrite,
|
||||
canDelete: !!sp.canDelete, canRemove: !!sp.canRemove
|
||||
})).slice(0, 6);
|
||||
|
||||
this.isEditable = !!(j.edit && j.edit.isEditable);
|
||||
this.remaining = (j.edit && j.edit.remainingSeconds) || 0;
|
||||
|
||||
this.startCountdown(); this.startSyncRemaining();
|
||||
} catch (e) { alert(e.message || 'Load error'); } finally { this.busy = false; }
|
||||
},
|
||||
|
||||
async save() {
|
||||
if (!this.isEditable) return;
|
||||
if (!this.validate()) return;
|
||||
try {
|
||||
this.busy = true;
|
||||
const dto = this.buildDto();
|
||||
const r = await fetch(`/ItRequestAPI/edit/${statusId}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(Object.assign({ statusId: +statusId }, dto))
|
||||
});
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(j.message || 'Save failed');
|
||||
if (typeof j.remainingSeconds === 'number') { this.remaining = j.remainingSeconds; this.startCountdown(); }
|
||||
} catch (e) { alert(e.message || 'Save error'); } finally { this.busy = false; }
|
||||
},
|
||||
|
||||
async sendNow() {
|
||||
if (!this.isEditable) return;
|
||||
if (!this.validate()) return;
|
||||
if (!confirm('Send to approvals now? You won’t be able to edit after this.')) return;
|
||||
try {
|
||||
this.busy = true;
|
||||
const r = await fetch(`/ItRequestAPI/sendNow/${statusId}`, { method: 'POST' });
|
||||
const j = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(j.message || 'Send failed');
|
||||
alert('Sent to approvals.');
|
||||
window.location.href = `/IT/ApprovalDashboard/MyRequests`;
|
||||
} catch (e) { alert(e.message || 'Send error'); } finally { this.busy = false; }
|
||||
},
|
||||
|
||||
async reload() { this.teardownTimers(); await this.load(); }
|
||||
},
|
||||
mounted() {
|
||||
this.load();
|
||||
window.addEventListener('beforeunload', this.teardownTimers);
|
||||
}
|
||||
});
|
||||
app.mount('#editApp');
|
||||
</script>
|
||||
}
|
||||
557
Areas/IT/Views/ApprovalDashboard/MyRequests.cshtml
Normal file
557
Areas/IT/Views/ApprovalDashboard/MyRequests.cshtml
Normal file
@ -0,0 +1,557 @@
|
||||
@{
|
||||
ViewData["Title"] = "My IT Requests";
|
||||
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||
}
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
|
||||
<style>
|
||||
.container-outer {
|
||||
max-width: 1300px;
|
||||
margin: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,.06);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filters .form-label {
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.nav-tabs .badge {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 18px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.empty i {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
margin-bottom: 6px;
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
position: relative;
|
||||
background: #f1f5f9;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.skeleton::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,.6), transparent);
|
||||
animation: shimmer 1.2s infinite;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
@@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.pillbar button {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
margin: 0 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.section-title .hint {
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="myReqApp" class="container-outer">
|
||||
<h3 class="mb-4 fw-bold"></h3>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="row mb-3 align-items-end filters">
|
||||
<div class="col-md-auto me-3">
|
||||
<label class="form-label">Month</label>
|
||||
<select class="form-control form-control-sm" v-model.number="selectedMonth" @@change="fetchData">
|
||||
<option v-for="(m, i) in months" :key="i" :value="i + 1">{{ m }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-auto">
|
||||
<label class="form-label">Year</label>
|
||||
<select class="form-control form-control-sm" v-model.number="selectedYear" @@change="fetchData">
|
||||
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
|
||||
<div v-if="busy" class="alert alert-secondary py-2">Loading…</div>
|
||||
|
||||
<!-- ===================== TABLE 1: MAIN REQUESTS (Draft/Pending/Approved/Rejected/Cancelled) ===================== -->
|
||||
<div class="table-container table-responsive">
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item"><a class="nav-link" :class="{ active: activeTab === 'Draft' }" href="#" @@click.prevent="switchTab('Draft')">Draft <span class="badge bg-info">{{ counts.draft }}</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" :class="{ active: activeTab === 'Pending' }" href="#" @@click.prevent="switchTab('Pending')">Pending <span class="badge bg-warning text-dark">{{ counts.pending }}</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" :class="{ active: activeTab === 'Approved' }" href="#" @@click.prevent="switchTab('Approved')">Approved <span class="badge bg-success">{{ counts.approved }}</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" :class="{ active: activeTab === 'Rejected' }" href="#" @@click.prevent="switchTab('Rejected')">Rejected <span class="badge bg-danger">{{ counts.rejected }}</span></a></li>
|
||||
<li class="nav-item"><a class="nav-link" :class="{ active: activeTab === 'Cancelled' }" href="#" @@click.prevent="switchTab('Cancelled')">Cancelled <span class="badge bg-secondary">{{ counts.cancelled }}</span></a></li>
|
||||
</ul>
|
||||
|
||||
<table class="table table-bordered table-sm table-striped align-middle text-center">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Department</th>
|
||||
<th>Company</th>
|
||||
<th>Required Date</th>
|
||||
<th>Date Submitted</th>
|
||||
<th style="width:260px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody v-if="busy">
|
||||
<tr v-for="n in 5" :key="'sk-main-'+n">
|
||||
<td colspan="5"><div class="skeleton"></div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
<tbody v-else>
|
||||
<tr v-for="r in paginatedData" :key="'row-'+activeTab+'-'+r.statusId">
|
||||
<td>{{ r.departmentName }}</td>
|
||||
<td>{{ r.companyName }}</td>
|
||||
<td>{{ fmtDate(r.requiredDate) }}</td>
|
||||
<td>{{ fmtDateTime(r.submitDate) }}</td>
|
||||
<td>
|
||||
<div class="d-flex justify-content-center align-items-center">
|
||||
<!-- View: Draft -> Edit page, others -> RequestReview -->
|
||||
<button class="btn btn-primary btn-sm me-1" @@click="view(r)">View</button>
|
||||
|
||||
<!-- Cancel: Draft only (server enforces final business rules) -->
|
||||
<button v-if="activeTab==='Draft'"
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
:disabled="cancellingId === r.itRequestId"
|
||||
@@click="cancel(r.itRequestId)">
|
||||
<span v-if="cancellingId === r.itRequestId" class="spinner-border spinner-border-sm me-1"></span>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!paginatedData.length" key="empty-main">
|
||||
<td colspan="5" class="empty"><i class="bi bi-inboxes"></i> No {{ activeTab.toLowerCase() }} requests</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination (Main) -->
|
||||
<div class="d-flex justify-content-between align-items-center mt-2" v-if="filteredData.length">
|
||||
<small class="text-muted">
|
||||
Showing {{ (currentPage - 1) * itemsPerPage + 1 }} – {{ Math.min(currentPage * itemsPerPage, filteredData.length) }}
|
||||
of {{ filteredData.length }}
|
||||
</small>
|
||||
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="text-muted">Rows</div>
|
||||
<select class="form-select form-select-sm" style="width:90px" v-model.number="itemsPerPage" @@change="currentPage=1">
|
||||
<option :value="5">5</option>
|
||||
<option :value="10">10</option>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
</select>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-outline-secondary btn-sm" :disabled="currentPage===1" @@click="currentPage--">Prev</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" :disabled="currentPage * itemsPerPage >= filteredData.length" @@click="currentPage++">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== TABLE 2: SECTION B (SECOND TABLE) ===================== -->
|
||||
<div class="table-container table-responsive">
|
||||
<div class="section-title">
|
||||
<span><i class="bi bi-clipboard-check"></i> Section B</span>
|
||||
<span class="hint">Requests with overall status Approved/Completed</span>
|
||||
</div>
|
||||
|
||||
<!-- Section B sub-filters + callout -->
|
||||
<div class="d-flex align-items-center justify-content-between flex-wrap mb-2">
|
||||
<div class="pillbar">
|
||||
<span class="text-muted me-2">Show:</span>
|
||||
<button type="button" class="btn btn-sm"
|
||||
:class="sbSubTab==='need' ? 'btn-primary' : 'btn-outline-secondary'"
|
||||
@@click="setSbSubTab('need')">
|
||||
Your Acceptance <span class="badge bg-light text-dark ms-1">{{ sbCount.need }}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm"
|
||||
:class="sbSubTab==='waiting' ? 'btn-primary' : 'btn-outline-secondary'"
|
||||
@@click="setSbSubTab('waiting')">
|
||||
Waiting IT <span class="badge bg-light text-dark ms-1">{{ sbCount.waiting }}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm"
|
||||
:class="sbSubTab==='notstarted' ? 'btn-primary' : 'btn-outline-secondary'"
|
||||
@@click="setSbSubTab('notstarted')">
|
||||
Not Started <span class="badge bg-light text-dark ms-1">{{ sbCount.notStarted }}</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm"
|
||||
:class="sbSubTab==='complete' ? 'btn-primary' : 'btn-outline-secondary'"
|
||||
@@click="setSbSubTab('complete')">
|
||||
Complete <span class="badge bg-light text-dark ms-1">{{ sbCount.complete }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="sbCount.need > 0" class="alert alert-warning py-1 px-2 m-0">
|
||||
You have {{ sbCount.need }} Section B {{ sbCount.need===1 ? 'item' : 'items' }} that need your acceptance.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="table table-bordered table-sm table-striped align-middle text-center">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Department</th>
|
||||
<th>Company</th>
|
||||
<th>Section B Status</th>
|
||||
<th style="width:360px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<!-- Skeleton while meta loads -->
|
||||
<tbody v-if="busy && !sbLoaded">
|
||||
<tr v-for="n in 4" :key="'sk-sb-'+n">
|
||||
<td colspan="4"><div class="skeleton"></div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
<!-- Section B rows -->
|
||||
<tbody v-else>
|
||||
<tr v-for="r in sectionBPage" :key="'sb-'+r.statusId">
|
||||
<td>{{ r.departmentName }}</td>
|
||||
<td>{{ r.companyName }}</td>
|
||||
<td>
|
||||
<span class="badge"
|
||||
:class="r.sb.itAccepted && r.sb.requestorAccepted ? 'bg-success'
|
||||
: (r.sb.saved ? 'bg-warning text-dark' : 'bg-secondary')">
|
||||
<template v-if="r.sb.itAccepted && r.sb.requestorAccepted">Approved</template>
|
||||
<template v-else-if="r.sb.saved && !r.sb.requestorAccepted">Your Acceptance</template>
|
||||
<template v-else-if="r.sb.saved && r.sb.requestorAccepted && !r.sb.itAccepted">Waiting IT</template>
|
||||
<template v-else>Not Started</template>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<!-- ACTION RULES BY SUBTAB -->
|
||||
<div class="d-flex justify-content-center align-items-center flex-wrap" style="gap:6px;">
|
||||
<!-- Always show View -->
|
||||
<button class="btn btn-primary btn-sm" @@click="openSectionB(r.statusId)">View</button>
|
||||
|
||||
<!-- Your Acceptance tab ONLY: show Accept (Requestor) -->
|
||||
<button v-if="sbSubTab==='need'"
|
||||
class="btn btn-outline-dark btn-sm"
|
||||
:disabled="busy || !r.sb.saved || r.sb.requestorAccepted"
|
||||
@@click="acceptRequestor(r.statusId)">
|
||||
Accept (Requestor)
|
||||
</button>
|
||||
|
||||
<!-- Complete tab ONLY: show PDF -->
|
||||
<button v-if="sbSubTab==='complete' && r.sb.itAccepted && r.sb.requestorAccepted"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
@@click="downloadPdf(r.statusId)">
|
||||
PDF
|
||||
</button>
|
||||
<!-- Not Started / Waiting IT: no other actions (View only) -->
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Empty state -->
|
||||
<tr v-if="sbLoaded && sectionBFiltered.length === 0">
|
||||
<td colspan="4" class="empty"><i class="bi bi-inboxes"></i> No Section B items in this view</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Pagination (Section B) -->
|
||||
<div class="d-flex justify-content-between align-items-center mt-2" v-if="sectionBFiltered.length">
|
||||
<small class="text-muted">
|
||||
Showing {{ (sectionBPageIndex - 1) * itemsPerPage + 1 }} – {{ Math.min(sectionBPageIndex * itemsPerPage, sectionBFiltered.length) }}
|
||||
of {{ sectionBFiltered.length }}
|
||||
</small>
|
||||
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-outline-secondary btn-sm" :disabled="sectionBPageIndex===1" @@click="sectionBPageIndex--">Prev</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" :disabled="sectionBPageIndex * itemsPerPage >= sectionBFiltered.length" @@click="sectionBPageIndex++">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
const app = Vue.createApp({
|
||||
data() {
|
||||
const now = new Date();
|
||||
return {
|
||||
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
|
||||
years: Array.from({ length: 10 }, (_, i) => now.getFullYear() - 5 + i),
|
||||
selectedMonth: now.getMonth() + 1,
|
||||
selectedYear: now.getFullYear(),
|
||||
|
||||
busy: false, error: null, allRows: [],
|
||||
// MAIN table
|
||||
activeTab: 'Pending',
|
||||
currentPage: 1, itemsPerPage: 10,
|
||||
cancellingId: 0,
|
||||
|
||||
// SECTION B (second table)
|
||||
sbMetaMap: {}, // statusId -> { saved, requestorAccepted, itAccepted, lastEditedBy }
|
||||
sbLoaded: false,
|
||||
sectionBPageIndex: 1,
|
||||
sbSubTab: 'need', // 'need' | 'waiting' | 'notstarted' | 'complete'
|
||||
|
||||
// Which overall statuses are eligible for Section B
|
||||
SECTIONB_ALLOW_STATUSES: ['Approved', 'Completed']
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
// Badge counts for main tabs
|
||||
counts() {
|
||||
const c = { draft: 0, pending: 0, approved: 0, rejected: 0, cancelled: 0 };
|
||||
this.allRows.forEach(r => {
|
||||
const s = (r.overallStatus || 'Pending');
|
||||
if (s === 'Draft') c.draft++;
|
||||
else if (s === 'Pending') c.pending++;
|
||||
else if (s === 'Approved') c.approved++;
|
||||
else if (s === 'Rejected') c.rejected++;
|
||||
else if (s === 'Cancelled') c.cancelled++;
|
||||
});
|
||||
return c;
|
||||
},
|
||||
|
||||
// MAIN table filtering & pagination
|
||||
filteredData() {
|
||||
const tab = this.activeTab;
|
||||
return this.allRows.filter(r => (r.overallStatus || 'Pending') === tab);
|
||||
},
|
||||
paginatedData() {
|
||||
const start = (this.currentPage - 1) * this.itemsPerPage;
|
||||
return this.filteredData.slice(start, start + this.itemsPerPage);
|
||||
},
|
||||
|
||||
// Build Section B candidate rows and sort by priority
|
||||
sectionBRows() {
|
||||
const rows = this.allRows
|
||||
.filter(r => this.SECTIONB_ALLOW_STATUSES.includes(r.overallStatus || ''))
|
||||
.map(r => ({
|
||||
...r,
|
||||
sb: this.sbMetaMap[r.statusId] || { saved: false, requestorAccepted: false, itAccepted: false, lastEditedBy: null }
|
||||
}));
|
||||
const rank = (x) => {
|
||||
if (x.sb.itAccepted && x.sb.requestorAccepted) return 3; // complete (lowest priority)
|
||||
if (!x.sb.saved) return 1; // not started
|
||||
if (x.sb.saved && x.sb.requestorAccepted && !x.sb.itAccepted) return 2; // waiting IT
|
||||
if (x.sb.saved && !x.sb.requestorAccepted) return 4; // needs requestor (highest)
|
||||
return 0;
|
||||
};
|
||||
return rows.slice().sort((a, b) => {
|
||||
const rb = rank(b) - rank(a);
|
||||
if (rb !== 0) return rb;
|
||||
return new Date(b.submitDate) - new Date(a.submitDate);
|
||||
});
|
||||
},
|
||||
|
||||
// Section B counts for pills
|
||||
sbCount() {
|
||||
const c = { need: 0, waiting: 0, notStarted: 0, complete: 0 };
|
||||
this.sectionBRows.forEach(r => {
|
||||
const st = this.sbStageOf(r.sb);
|
||||
if (st === 'need') c.need++;
|
||||
else if (st === 'waiting') c.waiting++;
|
||||
else if (st === 'notstarted') c.notStarted++;
|
||||
else if (st === 'complete') c.complete++;
|
||||
});
|
||||
return c;
|
||||
},
|
||||
|
||||
// Apply Section B sub-filter & pagination
|
||||
sectionBFiltered() {
|
||||
const want = this.sbSubTab; // 'need'|'waiting'|'notstarted'|'complete'
|
||||
return this.sectionBRows.filter(r => this.sbStageOf(r.sb) === want);
|
||||
},
|
||||
sectionBPage() {
|
||||
const start = (this.sectionBPageIndex - 1) * this.itemsPerPage;
|
||||
return this.sectionBFiltered.slice(start, start + this.itemsPerPage);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// MAIN table
|
||||
switchTab(tab) {
|
||||
this.activeTab = tab;
|
||||
this.currentPage = 1;
|
||||
},
|
||||
|
||||
async fetchData() {
|
||||
try {
|
||||
this.busy = true; this.error = null;
|
||||
const y = this.selectedYear, m = this.selectedMonth;
|
||||
|
||||
// Inclusive month range (UTC)
|
||||
const from = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0));
|
||||
const to = new Date(Date.UTC(y, m, 0, 23, 59, 59, 999));
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('from', from.toISOString());
|
||||
params.set('to', to.toISOString());
|
||||
params.set('page', '1');
|
||||
params.set('pageSize', '500');
|
||||
|
||||
const res = await fetch(`/ItRequestAPI/myRequests?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`Load failed (${res.status})`);
|
||||
const data = await res.json();
|
||||
|
||||
this.allRows = (data.data || []).map(x => ({
|
||||
itRequestId: x.itRequestId,
|
||||
statusId: x.statusId,
|
||||
departmentName: x.departmentName,
|
||||
companyName: x.companyName,
|
||||
requiredDate: x.requiredDate,
|
||||
submitDate: x.submitDate,
|
||||
overallStatus: x.overallStatus || 'Pending'
|
||||
}));
|
||||
|
||||
// Reset Section B cache and load fresh meta
|
||||
this.sbMetaMap = {};
|
||||
this.sbLoaded = false;
|
||||
await this.loadSectionBMeta();
|
||||
|
||||
} catch (e) {
|
||||
this.error = e.message || 'Failed to load.';
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
|
||||
fmtDate(d) { if (!d) return ''; const dt = new Date(d); return isNaN(dt) ? d : dt.toLocaleDateString(); },
|
||||
fmtDateTime(d) { if (!d) return ''; const dt = new Date(d); return isNaN(dt) ? d : dt.toLocaleString(); },
|
||||
|
||||
view(row) {
|
||||
if (this.activeTab === 'Draft') {
|
||||
window.location.href = `/IT/ApprovalDashboard/Edit?statusId=${row.statusId}`;
|
||||
} else {
|
||||
window.location.href = `/IT/ApprovalDashboard/RequestReview?statusId=${row.statusId}`;
|
||||
}
|
||||
},
|
||||
|
||||
async cancel(requestId) {
|
||||
if (!confirm('Cancel this request? This cannot be undone.')) return;
|
||||
this.cancellingId = requestId;
|
||||
try {
|
||||
const res = await fetch('/ItRequestAPI/cancel', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ requestId, reason: 'User requested cancellation' })
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.message || 'Cancel failed');
|
||||
await this.fetchData();
|
||||
} catch (e) {
|
||||
alert('Error: ' + (e.message || e));
|
||||
} finally {
|
||||
this.cancellingId = 0;
|
||||
}
|
||||
},
|
||||
|
||||
// ========= Section B (second table) =========
|
||||
openSectionB(statusId) {
|
||||
const here = window.location.pathname + window.location.search + window.location.hash;
|
||||
const returnUrl = encodeURIComponent(here);
|
||||
window.location.href = `/IT/ApprovalDashboard/SectionB?statusId=${statusId}&returnUrl=${returnUrl}`;
|
||||
},
|
||||
|
||||
async acceptRequestor(statusId) {
|
||||
try {
|
||||
this.busy = true;
|
||||
const res = await fetch('/ItRequestAPI/sectionB/accept', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ statusId, by: 'REQUESTOR' })
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.message || 'Accept failed');
|
||||
|
||||
// refresh only this row's meta
|
||||
await this.loadSectionBMeta([statusId]);
|
||||
} catch (e) {
|
||||
alert(e.message || 'Action failed');
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
|
||||
downloadPdf(statusId) { window.open(`/ItRequestAPI/sectionB/pdf?statusId=${statusId}`, '_blank'); },
|
||||
|
||||
// Derive requestor-centric stage
|
||||
sbStageOf(sb) {
|
||||
if (sb.itAccepted && sb.requestorAccepted) return 'complete';
|
||||
if (sb.saved && !sb.requestorAccepted) return 'need';
|
||||
if (sb.saved && sb.requestorAccepted && !sb.itAccepted) return 'waiting';
|
||||
return 'notstarted';
|
||||
},
|
||||
|
||||
// Load meta for all eligible Section B rows (or subset)
|
||||
async loadSectionBMeta(whichStatusIds = null) {
|
||||
try {
|
||||
const targets = (whichStatusIds && whichStatusIds.length)
|
||||
? whichStatusIds
|
||||
: this.allRows
|
||||
.filter(r => this.SECTIONB_ALLOW_STATUSES.includes(r.overallStatus || ''))
|
||||
.map(r => r.statusId);
|
||||
|
||||
if (!targets.length) { this.sbLoaded = true; return; }
|
||||
|
||||
for (const sid of targets) {
|
||||
const res = await fetch(`/ItRequestAPI/sectionB/meta?statusId=${sid}`);
|
||||
if (!res.ok) continue;
|
||||
const j = await res.json().catch(() => ({}));
|
||||
this.sbMetaMap[sid] = {
|
||||
saved: !!j.sectionB?.saved,
|
||||
requestorAccepted: !!j.requestorAccepted,
|
||||
itAccepted: !!j.itAccepted,
|
||||
lastEditedBy: j.sectionB?.lastEditedBy || null
|
||||
};
|
||||
}
|
||||
this.sbLoaded = true;
|
||||
} catch {
|
||||
this.sbLoaded = true;
|
||||
}
|
||||
},
|
||||
|
||||
setSbSubTab(tab) {
|
||||
this.sbSubTab = tab;
|
||||
this.sectionBPageIndex = 1;
|
||||
}
|
||||
},
|
||||
mounted() { this.fetchData(); }
|
||||
});
|
||||
app.mount('#myReqApp');
|
||||
</script>
|
||||
}
|
||||
632
Areas/IT/Views/ApprovalDashboard/RequestReview.cshtml
Normal file
632
Areas/IT/Views/ApprovalDashboard/RequestReview.cshtml
Normal file
@ -0,0 +1,632 @@
|
||||
@{
|
||||
ViewData["Title"] = "IT Request Review";
|
||||
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||
}
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
|
||||
<style>
|
||||
:root{
|
||||
--card-radius:16px;
|
||||
--soft-shadow:0 8px 24px rgba(0,0,0,.08);
|
||||
--soft-border:#eef2f6;
|
||||
--chip-pending:#ffe599; /* soft amber */
|
||||
--chip-approved:#b7e1cd; /* soft green */
|
||||
--chip-rejected:#f8b4b4; /* soft red */
|
||||
--text-muted:#6b7280;
|
||||
}
|
||||
|
||||
/* Shell */
|
||||
#reviewApp{
|
||||
max-width: 1200px;
|
||||
margin: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Page header */
|
||||
.page-head{
|
||||
display:flex; align-items:center; justify-content:space-between;
|
||||
gap:1rem; margin-bottom:1rem;
|
||||
}
|
||||
.page-title{
|
||||
display:flex; align-items:center; gap:.75rem; margin:0;
|
||||
font-weight:700; letter-spacing:.2px;
|
||||
}
|
||||
.subtle{
|
||||
color:var(--text-muted);
|
||||
font-weight:500;
|
||||
}
|
||||
|
||||
/* Card */
|
||||
.ui-card{
|
||||
background:#fff; border-radius:var(--card-radius);
|
||||
box-shadow:var(--soft-shadow); border:1px solid var(--soft-border);
|
||||
overflow:hidden; margin-bottom:18px;
|
||||
}
|
||||
.ui-card-head{
|
||||
display:flex; align-items:center; justify-content:space-between;
|
||||
padding:14px 18px; border-bottom:1px solid var(--soft-border);
|
||||
background:linear-gradient(180deg,#fbfdff, #f7fafc);
|
||||
}
|
||||
.ui-card-head h6{ margin:0; font-weight:700; color:#0b5ed7; }
|
||||
.ui-card-body{ padding:16px 18px; }
|
||||
|
||||
/* Requester grid */
|
||||
.req-grid{
|
||||
display:grid; grid-template-columns:repeat(2,1fr);
|
||||
gap:10px 18px;
|
||||
}
|
||||
@@media (max-width:768px){ .req-grid{ grid-template-columns:1fr; } }
|
||||
.req-line b{ color:#111827; }
|
||||
.req-line span{ color:var(--text-muted); }
|
||||
|
||||
/* Chips */
|
||||
.chip{
|
||||
display:inline-flex; align-items:center; gap:.4rem;
|
||||
padding:.3rem .6rem; border-radius:999px; font-weight:600; font-size:12px;
|
||||
border:1px solid rgba(0,0,0,.05);
|
||||
}
|
||||
.chip i{ font-size:14px; }
|
||||
.chip-pending{ background:var(--chip-pending); }
|
||||
.chip-approved{ background:var(--chip-approved); }
|
||||
.chip-rejected{ background:var(--chip-rejected); }
|
||||
.chip-muted{ background:#e5e7eb; }
|
||||
|
||||
/* Tables */
|
||||
.nice-table{
|
||||
width:100%; border-collapse:separate; border-spacing:0;
|
||||
overflow:hidden; border-radius:12px; border:1px solid var(--soft-border);
|
||||
}
|
||||
.nice-table thead th{
|
||||
background:#f3f6fb; color:#334155; font-weight:700; font-size:12px;
|
||||
text-transform:uppercase; letter-spacing:.4px; border-bottom:1px solid var(--soft-border);
|
||||
}
|
||||
.nice-table th, .nice-table td{ padding:10px 12px; vertical-align:middle; }
|
||||
.nice-table tbody tr + tr td{ border-top:1px solid #f1f5f9; }
|
||||
.nice-table tbody tr:hover{ background:#fafcff; }
|
||||
|
||||
/* Boolean badges in table */
|
||||
.yes-badge, .no-badge{
|
||||
display:inline-block; padding:.25rem .5rem; font-size:12px; font-weight:700;
|
||||
border-radius:999px;
|
||||
}
|
||||
.yes-badge{ background:#e7f7ed; color:#166534; border:1px solid #c7ecd3;}
|
||||
.no-badge{ background:#eef2f7; color:#334155; border:1px solid #e1e7ef;}
|
||||
|
||||
/* Empty state */
|
||||
.empty{
|
||||
text-align:center; padding:18px; color:var(--text-muted);
|
||||
}
|
||||
.empty i{ display:block; font-size:22px; margin-bottom:6px; opacity:.7; }
|
||||
|
||||
/* Skeletons */
|
||||
.skeleton{ position:relative; background:#f1f5f9; overflow:hidden; border-radius:6px; }
|
||||
.skeleton::after{
|
||||
content:""; position:absolute; inset:0;
|
||||
background:linear-gradient(90deg, transparent, rgba(255,255,255,.6), transparent);
|
||||
animation: shimmer 1.2s infinite;
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
@@keyframes shimmer{
|
||||
0%{ transform:translateX(-100%); }
|
||||
100%{ transform:translateX(100%); }
|
||||
}
|
||||
|
||||
/* Sticky action bar */
|
||||
.action-bar{
|
||||
position:sticky; bottom:12px; z-index:5;
|
||||
display:flex; justify-content:flex-end; gap:10px;
|
||||
padding:12px; border-radius:12px; background:rgba(255,255,255,.8);
|
||||
backdrop-filter: blur(6px);
|
||||
border:1px solid var(--soft-border); box-shadow:var(--soft-shadow);
|
||||
margin-top:8px;
|
||||
}
|
||||
|
||||
/* Soft buttons */
|
||||
.btn-soft{
|
||||
border-radius:10px; padding:.55rem .9rem; font-weight:700; letter-spacing:.2px;
|
||||
border:1px solid transparent; box-shadow:0 2px 8px rgba(0,0,0,.06);
|
||||
}
|
||||
.btn-approve{ background:#22c55e; color:#fff; }
|
||||
.btn-approve:hover{ background:#16a34a; }
|
||||
.btn-reject{ background:#ef4444; color:#fff; }
|
||||
.btn-reject:hover{ background:#dc2626; }
|
||||
.btn-disabled{ background:#e5e7eb; color:#6b7280; cursor:not-allowed; }
|
||||
</style>
|
||||
|
||||
<div id="reviewApp">
|
||||
<!-- Header -->
|
||||
<div class="page-head">
|
||||
<h3 class="page-title">
|
||||
</h3>
|
||||
<div>
|
||||
<span :class="overallChip.class">
|
||||
<i :class="overallChip.icon"></i>
|
||||
{{ overallChip.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requester Info -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-head">
|
||||
<h6><i class="bi bi-person-badge"></i> Requester Info</h6>
|
||||
<small class="subtle" v-if="!isLoading">Submitted: {{ formatDate(userInfo.submitDate) }}</small>
|
||||
<div v-else class="skeleton" style="height:14px; width:160px;"></div>
|
||||
</div>
|
||||
<div class="ui-card-body">
|
||||
<div class="req-grid">
|
||||
<div class="req-line">
|
||||
<b>Name</b><br>
|
||||
<span v-if="!isLoading">{{ userInfo.staffName || '—' }}</span>
|
||||
<div v-else class="skeleton" style="height:14px;"></div>
|
||||
</div>
|
||||
<div class="req-line">
|
||||
<b>Department</b><br>
|
||||
<span v-if="!isLoading">{{ userInfo.departmentName || '—' }}</span>
|
||||
<div v-else class="skeleton" style="height:14px;"></div>
|
||||
</div>
|
||||
<div class="req-line">
|
||||
<b>Company</b><br>
|
||||
<span v-if="!isLoading">{{ userInfo.companyName || '—' }}</span>
|
||||
<div v-else class="skeleton" style="height:14px;"></div>
|
||||
</div>
|
||||
<div class="req-line">
|
||||
<b>Designation</b><br>
|
||||
<span v-if="!isLoading">{{ userInfo.designation || '—' }}</span>
|
||||
<div v-else class="skeleton" style="height:14px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardware -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-head">
|
||||
<h6><i class="bi bi-cpu"></i> Hardware Requested</h6>
|
||||
</div>
|
||||
<div class="ui-card-body">
|
||||
<div v-if="isLoading">
|
||||
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
|
||||
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
|
||||
<div class="skeleton" style="height:36px;"></div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<table class="nice-table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Category</th>
|
||||
<th>Purpose</th>
|
||||
<th>Justification</th>
|
||||
<th>Other</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in hardware" :key="item.id">
|
||||
<td>{{ item.category }}</td>
|
||||
<td>{{ item.purpose }}</td>
|
||||
<td>{{ item.justification }}</td>
|
||||
<td>{{ item.otherDescription }}</td>
|
||||
</tr>
|
||||
<tr v-if="hardware.length === 0">
|
||||
<td colspan="4" class="empty">
|
||||
<i class="bi bi-inboxes"></i>
|
||||
No hardware requested
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-head">
|
||||
<h6><i class="bi bi-envelope-paper"></i> Email Requests</h6>
|
||||
</div>
|
||||
<div class="ui-card-body">
|
||||
<div v-if="isLoading">
|
||||
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
|
||||
<div class="skeleton" style="height:36px;"></div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<table class="nice-table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Purpose</th>
|
||||
<th>Proposed Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in emails" :key="item.id">
|
||||
<td>{{ item.purpose }}</td>
|
||||
<td>{{ item.proposedAddress }}</td>
|
||||
</tr>
|
||||
<tr v-if="emails.length === 0">
|
||||
<td colspan="2" class="empty">
|
||||
<i class="bi bi-inboxes"></i>
|
||||
No email requests
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OS Requirements -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-head">
|
||||
<h6><i class="bi bi-windows"></i> Operating System Requirements</h6>
|
||||
</div>
|
||||
<div class="ui-card-body">
|
||||
<div v-if="isLoading">
|
||||
<div class="skeleton" style="height:36px;"></div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<table class="nice-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Requirement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in osreqs" :key="item.id">
|
||||
<td>{{ item.requirementText }}</td>
|
||||
</tr>
|
||||
<tr v-if="osreqs.length === 0">
|
||||
<td class="empty">
|
||||
<i class="bi bi-inboxes"></i>
|
||||
No OS requirements
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Software -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-head">
|
||||
<h6><i class="bi bi-boxes"></i> Software Requested</h6>
|
||||
</div>
|
||||
<div class="ui-card-body">
|
||||
<div v-if="isLoading">
|
||||
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
|
||||
<div class="skeleton" style="height:36px;"></div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<table class="nice-table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bucket</th>
|
||||
<th>Name</th>
|
||||
<th>Other</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in software" :key="item.id">
|
||||
<td>{{ item.bucket }}</td>
|
||||
<td>{{ item.name }}</td>
|
||||
<td>{{ item.otherName }}</td>
|
||||
<td>{{ item.notes }}</td>
|
||||
</tr>
|
||||
<tr v-if="software.length === 0">
|
||||
<td colspan="4" class="empty">
|
||||
<i class="bi bi-inboxes"></i>
|
||||
No software requested
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared Permissions -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-head">
|
||||
<h6><i class="bi bi-folder-symlink"></i> Shared Folder / Permission Requests</h6>
|
||||
</div>
|
||||
<div class="ui-card-body">
|
||||
<div v-if="isLoading">
|
||||
<div class="skeleton" style="height:36px;"></div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<table class="nice-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Share Name</th>
|
||||
<th>Read</th>
|
||||
<th>Write</th>
|
||||
<th>Delete</th>
|
||||
<th>Remove</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in sharedPerms" :key="item.id">
|
||||
<td>{{ item.shareName }}</td>
|
||||
<td><span :class="item.canRead ? 'yes-badge' : 'no-badge'">{{ item.canRead ? 'Yes' : 'No' }}</span></td>
|
||||
<td><span :class="item.canWrite ? 'yes-badge' : 'no-badge'">{{ item.canWrite ? 'Yes' : 'No' }}</span></td>
|
||||
<td><span :class="item.canDelete ? 'yes-badge' : 'no-badge'">{{ item.canDelete ? 'Yes' : 'No' }}</span></td>
|
||||
<td><span :class="item.canRemove ? 'yes-badge' : 'no-badge'">{{ item.canRemove ? 'Yes' : 'No' }}</span></td>
|
||||
</tr>
|
||||
<tr v-if="sharedPerms.length === 0">
|
||||
<td colspan="5" class="empty">
|
||||
<i class="bi bi-inboxes"></i>
|
||||
No shared permissions requested
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Approval Trail -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-card-head">
|
||||
<h6><i class="bi bi-flag"></i> Approval Trail</h6>
|
||||
</div>
|
||||
<div class="ui-card-body">
|
||||
<div v-if="isLoading">
|
||||
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
|
||||
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
|
||||
<div class="skeleton" style="height:36px;"></div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<table class="nice-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Stage</th>
|
||||
<th>Status</th>
|
||||
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>HOD</td>
|
||||
<td><span :class="badgeChip(status.hodStatus).class"><i :class="badgeChip(status.hodStatus).icon"></i>{{ status.hodStatus || '—' }}</span></td>
|
||||
|
||||
<td>{{ formatDate(status.hodSubmitDate) || '—' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Group IT HOD</td>
|
||||
<td><span :class="badgeChip(status.gitHodStatus).class"><i :class="badgeChip(status.gitHodStatus).icon"></i>{{ status.gitHodStatus || '—' }}</span></td>
|
||||
|
||||
<td>{{ formatDate(status.gitHodSubmitDate) || '—' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Finance HOD</td>
|
||||
<td><span :class="badgeChip(status.finHodStatus).class"><i :class="badgeChip(status.finHodStatus).icon"></i>{{ status.finHodStatus || '—' }}</span></td>
|
||||
|
||||
<td>{{ formatDate(status.finHodSubmitDate) || '—' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Management</td>
|
||||
<td><span :class="badgeChip(status.mgmtStatus).class"><i :class="badgeChip(status.mgmtStatus).icon"></i>{{ status.mgmtStatus || '—' }}</span></td>
|
||||
|
||||
<td>{{ formatDate(status.mgmtSubmitDate) || '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Sticky Action Bar -->
|
||||
<div class="action-bar">
|
||||
<template v-if="status.canApprove">
|
||||
<button class="btn-soft btn-approve" @@click="updateStatus('Approved')">
|
||||
<i class="bi bi-check2-circle"></i> Approve
|
||||
</button>
|
||||
<button class="btn-soft btn-reject" @@click="updateStatus('Rejected')">
|
||||
<i class="bi bi-x-circle"></i> Reject
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="btn-soft btn-disabled" disabled>
|
||||
<i class="bi bi-shield-lock"></i> You cannot act on this request right now
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const reviewApp = Vue.createApp({
|
||||
data() {
|
||||
return {
|
||||
isLoading: true,
|
||||
userInfo: {
|
||||
staffName: "", departmentName: "", companyName: "",
|
||||
designation: "", submitDate: ""
|
||||
},
|
||||
hardware: [],
|
||||
emails: [],
|
||||
osreqs: [],
|
||||
software: [],
|
||||
sharedPerms: [],
|
||||
status: {
|
||||
hodStatus: "Pending", gitHodStatus: "Pending",
|
||||
finHodStatus: "Pending", mgmtStatus: "Pending",
|
||||
|
||||
hodSubmitDate: "", gitHodSubmitDate: "", finHodSubmitDate: "", mgmtSubmitDate: "",
|
||||
overallStatus: "Pending", canApprove: false
|
||||
}
|
||||
};
|
||||
},
|
||||
computed:{
|
||||
overallChip(){
|
||||
const s = (this.status.overallStatus || 'Pending').toLowerCase();
|
||||
if(s==='approved') return { class:'chip chip-approved', icon:'bi bi-check2-circle', text:'Overall: Approved' };
|
||||
if(s==='rejected') return { class:'chip chip-rejected', icon:'bi bi-x-circle', text:'Overall: Rejected' };
|
||||
return { class:'chip chip-pending', icon:'bi bi-hourglass-split', text:`Overall: ${this.status.overallStatus || 'Pending'}` };
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
badgeChip(v){
|
||||
const s = (v || 'Pending').toLowerCase();
|
||||
if(s==='approved') return { class:'chip chip-approved', icon:'bi bi-check2' };
|
||||
if(s==='rejected') return { class:'chip chip-rejected', icon:'bi bi-x-lg' };
|
||||
if(s==='pending') return { class:'chip chip-pending', icon:'bi bi-hourglass' };
|
||||
return { class:'chip chip-muted', icon:'bi bi-dot' };
|
||||
},
|
||||
|
||||
// ===== Load existing (full function) =====
|
||||
async loadRequest() {
|
||||
this.isLoading = true;
|
||||
|
||||
// 1) Read statusId from URL
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const statusId = params.get("statusId");
|
||||
if (!statusId) {
|
||||
alert("Missing statusId in URL");
|
||||
this.isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 2) Fetch request payload
|
||||
const res = await fetch(`/ItRequestAPI/request/${statusId}`);
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
let data, text;
|
||||
|
||||
if (ct.includes('application/json')) {
|
||||
data = await res.json();
|
||||
} else {
|
||||
text = await res.text();
|
||||
throw new Error(text || `HTTP ${res.status}`);
|
||||
}
|
||||
if (!res.ok) throw new Error(data?.message || `HTTP ${res.status}`);
|
||||
|
||||
console.log("RequestReview raw payload:", data);
|
||||
|
||||
// 3) Requester / submit metadata
|
||||
// Prefer top-level data.userInfo; if absent, fall back to data.request / data.Request
|
||||
const reqSrc = (data.userInfo ?? data.request ?? data.Request ?? {});
|
||||
|
||||
// Many APIs place submit date under status or request; pick the first available
|
||||
const submittedAt =
|
||||
data.status?.submitDate ?? data.status?.SubmitDate ??
|
||||
reqSrc.submitDate ?? reqSrc.SubmitDate ??
|
||||
data.status?.firstSubmittedAt ?? data.status?.FirstSubmittedAt ??
|
||||
data.request?.firstSubmittedAt ?? data.Request?.FirstSubmittedAt ?? "";
|
||||
|
||||
this.userInfo = {
|
||||
staffName: reqSrc.staffName ?? reqSrc.StaffName ?? "",
|
||||
departmentName: reqSrc.departmentName ?? reqSrc.DepartmentName ?? "",
|
||||
companyName: reqSrc.companyName ?? reqSrc.CompanyName ?? "",
|
||||
designation: reqSrc.designation ?? reqSrc.Designation ?? "",
|
||||
submitDate: submittedAt
|
||||
};
|
||||
|
||||
// 4) Hardware
|
||||
this.hardware = (data.hardware ?? []).map(x => ({
|
||||
id: x.id ?? x.Id,
|
||||
category: x.category ?? x.Category ?? "",
|
||||
purpose: x.purpose ?? x.Purpose ?? "",
|
||||
justification: x.justification ?? x.Justification ?? "",
|
||||
otherDescription: x.otherDescription ?? x.OtherDescription ?? ""
|
||||
}));
|
||||
|
||||
// 5) Emails
|
||||
this.emails = (data.emails ?? []).map(x => ({
|
||||
id: x.id ?? x.Id,
|
||||
purpose: x.purpose ?? x.Purpose ?? "",
|
||||
proposedAddress: x.proposedAddress ?? x.ProposedAddress ?? ""
|
||||
}));
|
||||
|
||||
// 6) OS requirements
|
||||
this.osreqs = (data.osreqs ?? data.OSReqs ?? []).map(x => ({
|
||||
id: x.id ?? x.Id,
|
||||
requirementText: x.requirementText ?? x.RequirementText ?? ""
|
||||
}));
|
||||
|
||||
// 7) Software
|
||||
this.software = (data.software ?? []).map(x => ({
|
||||
id: x.id ?? x.Id,
|
||||
bucket: x.bucket ?? x.Bucket ?? "",
|
||||
name: x.name ?? x.Name ?? "",
|
||||
otherName: x.otherName ?? x.OtherName ?? "",
|
||||
notes: x.notes ?? x.Notes ?? ""
|
||||
}));
|
||||
|
||||
// 8) Shared permissions
|
||||
this.sharedPerms = (data.sharedPerms ?? data.sharedPermissions ?? []).map(x => ({
|
||||
id: x.id ?? x.Id,
|
||||
shareName: x.shareName ?? x.ShareName ?? "",
|
||||
canRead: (x.canRead ?? x.CanRead) || false,
|
||||
canWrite: (x.canWrite ?? x.CanWrite) || false,
|
||||
canDelete: (x.canDelete ?? x.CanDelete) || false,
|
||||
canRemove: (x.canRemove ?? x.CanRemove) || false
|
||||
}));
|
||||
|
||||
// 9) Status block
|
||||
this.status = {
|
||||
hodStatus: data.status?.hodStatus ?? data.status?.HodStatus ?? "Pending",
|
||||
gitHodStatus: data.status?.gitHodStatus ?? data.status?.GitHodStatus ?? "Pending",
|
||||
finHodStatus: data.status?.finHodStatus ?? data.status?.FinHodStatus ?? "Pending",
|
||||
mgmtStatus: data.status?.mgmtStatus ?? data.status?.MgmtStatus ?? "Pending",
|
||||
|
||||
hodSubmitDate: data.status?.hodSubmitDate ?? data.status?.HodSubmitDate ?? "",
|
||||
gitHodSubmitDate: data.status?.gitHodSubmitDate ?? data.status?.GitHodSubmitDate ?? "",
|
||||
finHodSubmitDate: data.status?.finHodSubmitDate ?? data.status?.FinHodSubmitDate ?? "",
|
||||
mgmtSubmitDate: data.status?.mgmtSubmitDate ?? data.status?.MgmtSubmitDate ?? "",
|
||||
|
||||
overallStatus: data.status?.overallStatus ?? data.status?.OverallStatus ?? "Pending",
|
||||
canApprove: (data.status?.canApprove ?? data.status?.CanApprove) || false
|
||||
};
|
||||
|
||||
const os = (this.status.overallStatus || '').toLowerCase();
|
||||
if (os === 'cancelled' || os === 'draft') {
|
||||
this.status.canApprove = false;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("RequestReview fetch failed:", err);
|
||||
alert(`Failed to load request: ${err.message}`);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
async updateStatus(decision) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const statusId = params.get("statusId");
|
||||
try {
|
||||
const res = await fetch(`/ItRequestAPI/approveReject`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ statusId: parseInt(statusId), decision: decision })
|
||||
});
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
const payload = ct.includes('application/json') ? await res.json() : { message: await res.text() };
|
||||
if (!res.ok) throw new Error(payload?.message || `HTTP ${res.status}`);
|
||||
|
||||
// Small UX pop
|
||||
const verb = decision === 'Approved' ? 'approved' : 'rejected';
|
||||
alert(`Request ${verb} successfully.`);
|
||||
this.loadRequest();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert(`Failed to update status: ${e.message}`);
|
||||
}
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
try{
|
||||
// Show using local timezone, readable
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}catch{ return dateStr; }
|
||||
}
|
||||
},
|
||||
mounted() { this.loadRequest(); }
|
||||
});
|
||||
reviewApp.mount("#reviewApp");
|
||||
</script>
|
||||
360
Areas/IT/Views/ApprovalDashboard/SectionB.cshtml
Normal file
360
Areas/IT/Views/ApprovalDashboard/SectionB.cshtml
Normal file
@ -0,0 +1,360 @@
|
||||
@{
|
||||
ViewData["Title"] = "IT Section B – Asset Information";
|
||||
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||
// Expect ?statusId=123
|
||||
}
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
|
||||
<style>
|
||||
:root{ --card-r:16px; --soft-b:#eef2f6; --soft-s:0 8px 24px rgba(0,0,0,.08); --muted:#6b7280; }
|
||||
#bApp{ max-width:1100px; margin:auto; font-size:14px; }
|
||||
.ui-card{ background:#fff; border:1px solid var(--soft-b); border-radius:var(--card-r); box-shadow:var(--soft-s); margin-bottom:16px; overflow:hidden; }
|
||||
.ui-head{ display:flex; align-items:center; justify-content:space-between; padding:14px 18px; border-bottom:1px solid var(--soft-b); background:linear-gradient(180deg,#fbfdff,#f7fafc); }
|
||||
.ui-head h6{ margin:0; font-weight:800; color:#0b5ed7; }
|
||||
.ui-body{ padding:16px 18px; }
|
||||
.muted{ color:var(--muted); }
|
||||
.grid2{ display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||
@@media (max-width:768px){ .grid2{ grid-template-columns:1fr; } }
|
||||
.readonly-pill{ display:inline-flex; align-items:center; gap:.4rem; padding:.28rem .6rem; border-radius:999px; background:#eef2f7; color:#334155; font-weight:700; font-size:12px; }
|
||||
.sig-box{ border:1px dashed #d1d5db; border-radius:8px; padding:12px; background:#fbfbfc; }
|
||||
.submit-bar{ position:sticky; bottom:12px; z-index:5; display:flex; justify-content:flex-end; gap:10px; padding:12px; border-radius:12px; background:rgba(255,255,255,.85); backdrop-filter:blur(6px); border:1px solid var(--soft-b); box-shadow:var(--soft-s); margin:6px 0 30px; }
|
||||
</style>
|
||||
|
||||
<div id="bApp">
|
||||
<!-- Page head -->
|
||||
<div class="d-flex align-items-center justify-content-between my-3">
|
||||
<h5 class="m-0 fw-bold">Section B – Asset Information</h5>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="readonly-pill">Overall: {{ meta.overallStatus || '—' }}</span>
|
||||
<span class="readonly-pill">Requestor: {{ meta.requestorName || '—' }}</span>
|
||||
<span class="readonly-pill" v-if="meta.isItMember">You are IT</span>
|
||||
<span class="readonly-pill" :class="meta.sectionBSent ? '' : 'bg-warning-subtle'">
|
||||
{{ meta.sectionBSent ? 'Sent' : 'Draft' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
|
||||
<div v-if="busy" class="alert alert-secondary py-2">Loading…</div>
|
||||
|
||||
<!-- Gate -->
|
||||
<div v-if="!busy && meta.overallStatus !== 'Approved'" class="alert alert-warning">
|
||||
Section B is available only after the request is <strong>Approved</strong>. Current status: <strong>{{ meta.overallStatus || '—' }}</strong>.
|
||||
</div>
|
||||
|
||||
<template v-if="!busy && meta.overallStatus === 'Approved'">
|
||||
<!-- Asset Information -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-head">
|
||||
<h6><i class="bi bi-hdd-stack"></i> Asset Information</h6>
|
||||
<span class="muted" v-if="meta.lastEditedBy">
|
||||
Last edited by {{ meta.lastEditedBy }} at {{ fmtDT(meta.lastEditedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div v-if="!meta.isItMember" class="alert alert-info py-2">
|
||||
You’re not in the IT Team. You can view once saved, but cannot edit.
|
||||
</div>
|
||||
<div v-if="meta.locked" class="alert alert-light border">
|
||||
Locked for editing (sent). Acceptances can proceed below.
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<label class="form-label">Asset No</label>
|
||||
<input class="form-control" v-model.trim="form.assetNo" :disabled="!meta.isItMember || meta.locked">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Machine ID</label>
|
||||
<input class="form-control" v-model.trim="form.machineId" :disabled="!meta.isItMember || meta.locked">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">IP Address</label>
|
||||
<input class="form-control" v-model.trim="form.ipAddress" :disabled="!meta.isItMember || meta.locked">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Wired MAC Address</label>
|
||||
<input class="form-control" v-model.trim="form.wiredMac" :disabled="!meta.isItMember || meta.locked">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Wi-Fi MAC Address</label>
|
||||
<input class="form-control" v-model.trim="form.wifiMac" :disabled="!meta.isItMember || meta.locked">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label">Dial-up Account</label>
|
||||
<input class="form-control" v-model.trim="form.dialupAcc" :disabled="!meta.isItMember || meta.locked">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Remarks</label>
|
||||
<textarea rows="4" class="form-control" v-model.trim="form.remarks" :disabled="!meta.isItMember || meta.locked"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Acceptances -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-head">
|
||||
<h6><i class="bi bi-patch-check"></i> Acceptances</h6>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div v-if="!meta.sectionBSent" class="alert alert-light border">
|
||||
Send Section B first to enable acceptances.
|
||||
</div>
|
||||
|
||||
<div class="row g-3" v-else>
|
||||
<div class="col-md-6">
|
||||
<div class="sig-box">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<strong>Requestor Acknowledgement</strong>
|
||||
<span class="badge" :class="meta.requestorAccepted ? 'bg-success' : 'bg-secondary'">
|
||||
{{ meta.requestorAccepted ? 'Accepted' : 'Pending' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div>Name: <strong>{{ meta.requestorName || '—' }}</strong></div>
|
||||
<div>Date: <strong>{{ fmtDT(meta.requestorAcceptedAt) || '—' }}</strong></div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-outline-primary btn-sm"
|
||||
:disabled="busy || !meta.isRequestor || meta.requestorAccepted"
|
||||
@@click="accept('REQUESTOR')">
|
||||
I Accept
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="sig-box">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<strong>Completed by (IT)</strong>
|
||||
<span class="badge" :class="meta.itAccepted ? 'bg-success' : 'bg-secondary'">
|
||||
{{ meta.itAccepted ? 'Accepted' : 'Pending' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div>Name: <strong>{{ meta.itAcceptedBy || (meta.isItMember ? '(you?)' : '—') }}</strong></div>
|
||||
<div>Date: <strong>{{ fmtDT(meta.itAcceptedAt) || '—' }}</strong></div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-outline-primary btn-sm"
|
||||
:disabled="busy || !meta.isItMember || meta.itAccepted"
|
||||
@@click="accept('IT')">
|
||||
IT Accept
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-outline-secondary btn-sm mt-2"
|
||||
:disabled="busy || !(meta.requestorAccepted && meta.itAccepted)"
|
||||
@@click="downloadPdf">
|
||||
Download PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky action bar -->
|
||||
<div class="submit-bar">
|
||||
<button class="btn btn-light me-auto" @@click="goBack">
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
</button>
|
||||
<button class="btn btn-secondary"
|
||||
:disabled="busy || !meta.isItMember || meta.locked"
|
||||
@@click="resetDraft">
|
||||
Reset
|
||||
</button>
|
||||
<button class="btn btn-primary"
|
||||
:disabled="busy || !meta.isItMember || meta.locked"
|
||||
@@click="saveDraft">
|
||||
<span v-if="saving" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Save Draft
|
||||
</button>
|
||||
<button class="btn btn-success"
|
||||
:disabled="busy || !meta.isItMember || meta.locked"
|
||||
@@click="sendNow">
|
||||
Send Now
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@section Scripts{
|
||||
<script>
|
||||
const bApp = Vue.createApp({
|
||||
data(){
|
||||
const url = new URL(window.location.href);
|
||||
return {
|
||||
busy:false, saving:false, error:null,
|
||||
statusId: Number(url.searchParams.get('statusId')) || 0,
|
||||
returnUrl: url.searchParams.get('returnUrl'),
|
||||
meta:{
|
||||
overallStatus:null, requestorName:null,
|
||||
isRequestor:false, isItMember:false,
|
||||
sectionBSent:false, sectionBSentAt:null,
|
||||
locked:false,
|
||||
lastEditedBy:null, lastEditedAt:null,
|
||||
requestorAccepted:false, requestorAcceptedAt:null,
|
||||
itAccepted:false, itAcceptedAt:null, itAcceptedBy:null
|
||||
},
|
||||
form:{ assetNo:"", machineId:"", ipAddress:"", wiredMac:"", wifiMac:"", dialupAcc:"", remarks:"" }
|
||||
};
|
||||
},
|
||||
methods:{
|
||||
|
||||
goBack() {
|
||||
const go = (u) => window.location.href = u;
|
||||
|
||||
// 1) Prefer returnUrl if it's local
|
||||
if (this.returnUrl) {
|
||||
try {
|
||||
const u = new URL(this.returnUrl, window.location.origin);
|
||||
if (u.origin === window.location.origin) { go(u.href); return; }
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// 2) Else use Referer if it's local
|
||||
if (document.referrer) {
|
||||
try {
|
||||
const u = new URL(document.referrer);
|
||||
if (u.origin === window.location.origin) { go(document.referrer); return; }
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// 3) Else use history
|
||||
if (history.length > 1) { history.back(); return; }
|
||||
|
||||
// 4) Final hard fallback by role
|
||||
go(this.meta.isItMember ? '/IT/ApprovalDashboard' : '/IT/MyRequests');
|
||||
},
|
||||
|
||||
fmtDT(d){ if(!d) return ""; const dt=new Date(d); return isNaN(dt)?"":dt.toLocaleString(); },
|
||||
async load(){
|
||||
try{
|
||||
this.busy=true; this.error=null;
|
||||
const r = await fetch(`/ItRequestAPI/sectionB/meta?statusId=${this.statusId}`);
|
||||
if(!r.ok) throw new Error(`Load failed (${r.status})`);
|
||||
const j = await r.json();
|
||||
|
||||
this.meta = {
|
||||
overallStatus: j.overallStatus,
|
||||
requestorName: j.requestorName,
|
||||
isRequestor: j.isRequestor,
|
||||
isItMember: j.isItMember,
|
||||
sectionBSent: !!j.sectionBSent,
|
||||
sectionBSentAt: j.sectionBSentAt,
|
||||
locked: !!j.locked,
|
||||
lastEditedBy: j.sectionB?.lastEditedBy || null,
|
||||
lastEditedAt: j.sectionB?.lastEditedAt || null,
|
||||
requestorAccepted: !!j.requestorAccepted,
|
||||
requestorAcceptedAt: j.requestorAcceptedAt || null,
|
||||
itAccepted: !!j.itAccepted,
|
||||
itAcceptedAt: j.itAcceptedAt || null,
|
||||
itAcceptedBy: j.itAcceptedBy || null
|
||||
};
|
||||
|
||||
const s = j.sectionB || {};
|
||||
this.form = {
|
||||
assetNo: s.assetNo || "",
|
||||
machineId: s.machineId || "",
|
||||
ipAddress: s.ipAddress || "",
|
||||
wiredMac: s.wiredMac || "",
|
||||
wifiMac: s.wifiMac || "",
|
||||
dialupAcc: s.dialupAcc || "",
|
||||
remarks: s.remarks || ""
|
||||
};
|
||||
}catch(e){
|
||||
this.error = e.message || 'Failed to load.';
|
||||
}finally{
|
||||
this.busy=false;
|
||||
}
|
||||
},
|
||||
async saveDraft(){
|
||||
try{
|
||||
this.saving=true; this.error=null;
|
||||
const res = await fetch('/ItRequestAPI/sectionB/save', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ statusId:this.statusId, ...this.form })
|
||||
});
|
||||
const j = await res.json().catch(()=> ({}));
|
||||
if(!res.ok) throw new Error(j.message || `Save failed (${res.status})`);
|
||||
await this.load();
|
||||
}catch(e){ this.error = e.message || 'Unable to save.'; }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
async resetDraft(){
|
||||
if(!confirm('Reset Section B to an empty draft?')) return;
|
||||
try{
|
||||
this.busy=true; this.error=null;
|
||||
const res = await fetch('/ItRequestAPI/sectionB/reset', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ statusId:this.statusId })
|
||||
});
|
||||
const j = await res.json().catch(()=> ({}));
|
||||
if(!res.ok) throw new Error(j.message || `Reset failed (${res.status})`);
|
||||
await this.load();
|
||||
}catch(e){ this.error = e.message || 'Unable to reset.'; }
|
||||
finally{ this.busy=false; }
|
||||
},
|
||||
async sendNow() {
|
||||
if (!confirm('Send Section B now? You will not be able to edit afterwards.')) return;
|
||||
|
||||
// optional guard (mirrors server rule)
|
||||
const a = (this.form.assetNo || "").trim(), m = (this.form.machineId || "").trim(), i = (this.form.ipAddress || "").trim();
|
||||
if (!a && !m && !i) { alert('Please provide at least Asset No, Machine ID, or IP Address.'); return; }
|
||||
|
||||
try {
|
||||
this.busy = true; this.error = null;
|
||||
const res = await fetch('/ItRequestAPI/sectionB/send', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
statusId: this.statusId,
|
||||
assetNo: this.form.assetNo,
|
||||
machineId: this.form.machineId,
|
||||
ipAddress: this.form.ipAddress,
|
||||
wiredMac: this.form.wiredMac,
|
||||
wifiMac: this.form.wifiMac,
|
||||
dialupAcc: this.form.dialupAcc,
|
||||
remarks: this.form.remarks
|
||||
})
|
||||
});
|
||||
const j = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(j.message || `Send failed (${res.status})`);
|
||||
alert(j.message || 'Section B sent and locked for editing.');
|
||||
await this.load();
|
||||
} catch (e) {
|
||||
this.error = e.message || 'Unable to send.';
|
||||
} finally {
|
||||
this.busy = false;
|
||||
}
|
||||
},
|
||||
|
||||
async accept(kind){
|
||||
try{
|
||||
this.busy=true; this.error=null;
|
||||
const res = await fetch('/ItRequestAPI/sectionB/accept', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ statusId:this.statusId, by: kind })
|
||||
});
|
||||
const j = await res.json().catch(()=> ({}));
|
||||
if(!res.ok) throw new Error(j.message || `Accept failed (${res.status})`);
|
||||
await this.load();
|
||||
}catch(e){ this.error = e.message || 'Action failed.'; }
|
||||
finally{ this.busy=false; }
|
||||
},
|
||||
downloadPdf(){
|
||||
window.open(`/ItRequestAPI/sectionB/pdf?statusId=${this.statusId}`,'_blank');
|
||||
}
|
||||
},
|
||||
mounted(){
|
||||
if(!this.statusId){ this.error='Missing statusId in the URL.'; return; }
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
bApp.mount('#bApp');
|
||||
</script>
|
||||
}
|
||||
321
Areas/IT/Views/ApprovalDashboard/SectionBEdit.cshtml
Normal file
321
Areas/IT/Views/ApprovalDashboard/SectionBEdit.cshtml
Normal file
@ -0,0 +1,321 @@
|
||||
@{
|
||||
ViewData["Title"] = "Edit Section B – Asset Information";
|
||||
Layout = "~/Views/Shared/_Layout.cshtml";
|
||||
// ?statusId=123
|
||||
}
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
|
||||
|
||||
<style>
|
||||
:root{ --card-r:16px; --soft-b:#eef2f6; --soft-s:0 8px 24px rgba(0,0,0,.08); --muted:#6b7280; }
|
||||
#sbEditApp{ max-width:1100px; margin:auto; font-size:14px; }
|
||||
.ui-card{ background:#fff; border:1px solid var(--soft-b); border-radius:var(--card-r); box-shadow:var(--soft-s); margin-bottom:16px; overflow:hidden; }
|
||||
.ui-head{ display:flex; align-items:center; justify-content:space-between; padding:14px 18px; border-bottom:1px solid var(--soft-b); background:linear-gradient(180deg,#fbfdff,#f7fafc); }
|
||||
.ui-head h6{ margin:0; font-weight:800; color:#0b5ed7; }
|
||||
.ui-body{ padding:16px 18px; }
|
||||
.muted{ color:var(--muted); }
|
||||
.grid2{ display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||
@@media (max-width:768px){ .grid2{ grid-template-columns:1fr; } }
|
||||
.readonly-pill{ display:inline-flex; align-items:center; gap:.4rem; padding:.28rem .6rem; border-radius:999px; background:#eef2f7; color:#334155; font-weight:700; font-size:12px; }
|
||||
.sig-box{ border:1px dashed #d1d5db; border-radius:8px; padding:12px; background:#fbfbfc; }
|
||||
.submit-bar{ position:sticky; bottom:12px; z-index:5; display:flex; justify-content:flex-end; gap:10px; padding:12px; border-radius:12px; background:rgba(255,255,255,.85); backdrop-filter:blur(6px); border:1px solid var(--soft-b); box-shadow:var(--soft-s); margin:6px 0 30px; }
|
||||
</style>
|
||||
|
||||
<div id="sbEditApp">
|
||||
<!-- Page head -->
|
||||
<div class="d-flex align-items-center justify-content-between my-3">
|
||||
<h5 class="m-0 fw-bold">Edit Section B – Asset Information</h5>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="readonly-pill">Overall: {{ meta.overallStatus || '—' }}</span>
|
||||
<span class="readonly-pill">Requestor: {{ meta.requestorName || '—' }}</span>
|
||||
<span class="readonly-pill" v-if="meta.isItMember">You are IT</span>
|
||||
<span class="readonly-pill" :class="meta.sectionBSent ? '' : 'bg-warning-subtle'">
|
||||
{{ meta.sectionBSent ? 'Sent' : 'Draft' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
|
||||
<div v-if="busy" class="alert alert-secondary py-2">Loading…</div>
|
||||
|
||||
<!-- Gate -->
|
||||
<div v-if="!busy && meta.overallStatus !== 'Approved'" class="alert alert-warning">
|
||||
Section B is available only after the request is <strong>Approved</strong>. Current status: <strong>{{ meta.overallStatus || '—' }}</strong>.
|
||||
</div>
|
||||
|
||||
<template v-if="!busy && meta.overallStatus === 'Approved'">
|
||||
<!-- Asset info -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-head">
|
||||
<h6><i class="bi bi-hdd-stack"></i> Asset Information</h6>
|
||||
<span class="muted" v-if="meta.lastEditedBy">
|
||||
Last edited by {{ meta.lastEditedBy }} at {{ fmtDT(meta.lastEditedAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div v-if="!meta.isItMember" class="alert alert-info py-2">
|
||||
You’re not in the IT Team. You can view once saved, but cannot edit.
|
||||
</div>
|
||||
<div v-if="meta.locked" class="alert alert-light border">
|
||||
Locked for editing (sent). Acceptances can proceed in Section B page.
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
<div><label class="form-label">Asset No</label>
|
||||
<input class="form-control" v-model.trim="form.assetNo" :disabled="!meta.isItMember || meta.locked">
|
||||
</div>
|
||||
<div><label class="form-label">Machine ID</label>
|
||||
<input class="form-control" v-model.trim="form.machineId" :disabled="!meta.isItMember || meta.locked">
|
||||
</div>
|
||||
<div><label class="form-label">IP Address</label>
|
||||
<input class="form-control" v-model.trim="form.ipAddress" :disabled="!meta.isItMember || meta.locked">
|
||||
</div>
|
||||
<div><label class="form-label">Wired MAC Address</label>
|
||||
<input class="form-control" v-model.trim="form.wiredMac" :disabled="!meta.isItMember || meta.locked">
|
||||
</div>
|
||||
<div><label class="form-label">Wi-Fi MAC Address</label>
|
||||
<input class="form-control" v-model.trim="form.wifiMac" :disabled="!meta.isItMember || meta.locked">
|
||||
</div>
|
||||
<div><label class="form-label">Dial-up Account</label>
|
||||
<input class="form-control" v-model.trim="form.dialupAcc" :disabled="!meta.isItMember || meta.locked">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<label class="form-label">Remarks</label>
|
||||
<textarea rows="4" class="form-control" v-model.trim="form.remarks" :disabled="!meta.isItMember || meta.locked"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Acceptances (summary + IT accept button, gated by sent) -->
|
||||
<div class="ui-card">
|
||||
<div class="ui-head">
|
||||
<h6><i class="bi bi-patch-check"></i> Acceptances</h6>
|
||||
</div>
|
||||
<div class="ui-body">
|
||||
<div v-if="!meta.sectionBSent" class="alert alert-light border">
|
||||
Send Section B first to enable acceptances.
|
||||
</div>
|
||||
|
||||
<div class="row g-3" v-else>
|
||||
<div class="col-md-6">
|
||||
<div class="sig-box">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<strong>Requestor Acknowledgement</strong>
|
||||
<span class="badge" :class="meta.requestorAccepted ? 'bg-success' : 'bg-secondary'">
|
||||
{{ meta.requestorAccepted ? 'Accepted' : 'Pending' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div>Name: <strong>{{ meta.requestorName || '—' }}</strong></div>
|
||||
<div>Date: <strong>{{ fmtDT(meta.requestorAcceptedAt) || '—' }}</strong></div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<a class="btn btn-outline-primary btn-sm"
|
||||
:href="`/IT/ApprovalDashboard/SectionB?statusId=${statusId}`">
|
||||
Open Section B (requestor view)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="sig-box">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<strong>Completed by (IT)</strong>
|
||||
<span class="badge" :class="meta.itAccepted ? 'bg-success' : 'bg-secondary'">
|
||||
{{ meta.itAccepted ? 'Accepted' : 'Pending' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div>Name: <strong>{{ meta.itAcceptedBy || (meta.isItMember ? '(you?)' : '—') }}</strong></div>
|
||||
<div>Date: <strong>{{ fmtDT(meta.itAcceptedAt) || '—' }}</strong></div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-outline-primary btn-sm"
|
||||
:disabled="busy || !meta.isItMember || meta.itAccepted"
|
||||
@@click="accept('IT')">
|
||||
IT Accept
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-outline-secondary btn-sm mt-2"
|
||||
:disabled="busy || !(meta.requestorAccepted && meta.itAccepted)"
|
||||
@@click="downloadPdf">
|
||||
Download PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sticky action bar (same layout as Section B) -->
|
||||
<div class="submit-bar">
|
||||
<button class="btn btn-light me-auto" @@click="goBack">
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
</button>
|
||||
|
||||
<button class="btn btn-secondary" :disabled="busy || !meta.isItMember || meta.locked" @@click="resetDraft">Reset</button>
|
||||
<button class="btn btn-primary" :disabled="busy || !meta.isItMember || meta.locked" @@click="saveDraft">
|
||||
<span v-if="saving" class="spinner-border spinner-border-sm me-2"></span>
|
||||
Save Draft
|
||||
</button>
|
||||
<button class="btn btn-success" :disabled="busy || !meta.isItMember || meta.locked" @@click="sendNow">Send Now</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@section Scripts{
|
||||
<script>
|
||||
const sbEditApp = Vue.createApp({
|
||||
data(){
|
||||
const url = new URL(window.location.href);
|
||||
return {
|
||||
statusId: Number(url.searchParams.get('statusId')) || 0,
|
||||
returnUrl: url.searchParams.get('returnUrl'),
|
||||
busy:false, saving:false, error:null,
|
||||
meta:{
|
||||
overallStatus:null, requestorName:null,
|
||||
isRequestor:false, isItMember:false,
|
||||
sectionBSent:false, sectionBSentAt:null,
|
||||
locked:false,
|
||||
lastEditedBy:null, lastEditedAt:null,
|
||||
requestorAccepted:false, requestorAcceptedAt:null,
|
||||
itAccepted:false, itAcceptedAt:null, itAcceptedBy:null
|
||||
},
|
||||
form:{ assetNo:"", machineId:"", ipAddress:"", wiredMac:"", wifiMac:"", dialupAcc:"", remarks:"" }
|
||||
};
|
||||
},
|
||||
methods:{
|
||||
|
||||
goBack() {
|
||||
const go = (u) => window.location.href = u;
|
||||
|
||||
// 1) Prefer explicit returnUrl (only if local)
|
||||
if (this.returnUrl) {
|
||||
try {
|
||||
const u = new URL(this.returnUrl, window.location.origin);
|
||||
if (u.origin === window.location.origin) { go(u.href); return; }
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// 2) Else use Referer (only if local)
|
||||
if (document.referrer) {
|
||||
try {
|
||||
const u = new URL(document.referrer);
|
||||
if (u.origin === window.location.origin) { go(document.referrer); return; }
|
||||
} catch { }
|
||||
}
|
||||
|
||||
// 3) Else browser history
|
||||
if (history.length > 1) { history.back(); return; }
|
||||
|
||||
// 4) Final fallback based on role
|
||||
go(this.meta.isItMember ? '/IT/ApprovalDashboard' : '/IT/MyRequests');
|
||||
},
|
||||
|
||||
fmtDT(d){ if(!d) return ""; const dt=new Date(d); return isNaN(dt)?"":dt.toLocaleString(); },
|
||||
async load(){
|
||||
try{
|
||||
this.busy=true; this.error=null;
|
||||
const r = await fetch(`/ItRequestAPI/sectionB/meta?statusId=${this.statusId}`);
|
||||
if(!r.ok) throw new Error(`Load failed (${r.status})`);
|
||||
const j = await r.json();
|
||||
|
||||
this.meta = {
|
||||
overallStatus: j.overallStatus,
|
||||
requestorName: j.requestorName,
|
||||
isRequestor: j.isRequestor,
|
||||
isItMember: j.isItMember,
|
||||
sectionBSent: !!j.sectionBSent,
|
||||
sectionBSentAt: j.sectionBSentAt,
|
||||
locked: !!j.locked,
|
||||
lastEditedBy: j.sectionB?.lastEditedBy || null,
|
||||
lastEditedAt: j.sectionB?.lastEditedAt || null,
|
||||
requestorAccepted: !!j.requestorAccepted,
|
||||
requestorAcceptedAt: j.requestorAcceptedAt || null,
|
||||
itAccepted: !!j.itAccepted,
|
||||
itAcceptedAt: j.itAcceptedAt || null,
|
||||
itAcceptedBy: j.itAcceptedBy || null
|
||||
};
|
||||
|
||||
const s = j.sectionB || {};
|
||||
this.form = {
|
||||
assetNo: s.assetNo || "",
|
||||
machineId: s.machineId || "",
|
||||
ipAddress: s.ipAddress || "",
|
||||
wiredMac: s.wiredMac || "",
|
||||
wifiMac: s.wifiMac || "",
|
||||
dialupAcc: s.dialupAcc || "",
|
||||
remarks: s.remarks || ""
|
||||
};
|
||||
}catch(e){ this.error = e.message || 'Failed to load.'; }
|
||||
finally{ this.busy=false; }
|
||||
},
|
||||
async saveDraft(){
|
||||
try{
|
||||
this.saving=true; this.error=null;
|
||||
const res = await fetch('/ItRequestAPI/sectionB/save', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ statusId:this.statusId, ...this.form })
|
||||
});
|
||||
const j = await res.json().catch(()=> ({}));
|
||||
if(!res.ok) throw new Error(j.message || `Save failed (${res.status})`);
|
||||
await this.load();
|
||||
}catch(e){ this.error = e.message || 'Unable to save.'; }
|
||||
finally{ this.saving=false; }
|
||||
},
|
||||
async resetDraft(){
|
||||
if(!confirm('Reset Section B to an empty draft?')) return;
|
||||
try{
|
||||
this.busy=true; this.error=null;
|
||||
const res = await fetch('/ItRequestAPI/sectionB/reset', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ statusId:this.statusId })
|
||||
});
|
||||
const j = await res.json().catch(()=> ({}));
|
||||
if(!res.ok) throw new Error(j.message || `Reset failed (${res.status})`);
|
||||
await this.load();
|
||||
}catch(e){ this.error = e.message || 'Unable to reset.'; }
|
||||
finally{ this.busy=false; }
|
||||
},
|
||||
async sendNow(){
|
||||
if(!confirm('Send Section B now? You will not be able to edit afterwards.')) return;
|
||||
try{
|
||||
this.busy=true; this.error=null;
|
||||
const res = await fetch('/ItRequestAPI/sectionB/send', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ statusId:this.statusId })
|
||||
});
|
||||
const j = await res.json().catch(()=> ({}));
|
||||
if(!res.ok) throw new Error(j.message || `Send failed (${res.status})`);
|
||||
alert(j.message || 'Section B sent and locked for editing.');
|
||||
await this.load();
|
||||
}catch(e){ this.error = e.message || 'Unable to send.'; }
|
||||
finally{ this.busy=false; }
|
||||
},
|
||||
async accept(kind){ // IT accept from here
|
||||
try{
|
||||
this.busy=true; this.error=null;
|
||||
const res = await fetch('/ItRequestAPI/sectionB/accept', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({ statusId:this.statusId, by: kind })
|
||||
});
|
||||
const j = await res.json().catch(()=> ({}));
|
||||
if(!res.ok) throw new Error(j.message || `Accept failed (${res.status})`);
|
||||
await this.load();
|
||||
}catch(e){ this.error = e.message || 'Action failed.'; }
|
||||
finally{ this.busy=false; }
|
||||
},
|
||||
downloadPdf(){ window.open(`/ItRequestAPI/sectionB/pdf?statusId=${this.statusId}`,'_blank'); }
|
||||
},
|
||||
mounted(){
|
||||
if(!this.statusId){ this.error='Missing statusId in the URL.'; return; }
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
sbEditApp.mount('#sbEditApp');
|
||||
</script>
|
||||
}
|
||||
83
Areas/Identity/Pages/Account/AccessDenied.cshtml
Normal file
83
Areas/Identity/Pages/Account/AccessDenied.cshtml
Normal file
@ -0,0 +1,83 @@
|
||||
@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>
|
||||
}
|
||||
23
Areas/Identity/Pages/Account/AccessDenied.cshtml.cs
Normal file
23
Areas/Identity/Pages/Account/AccessDenied.cshtml.cs
Normal file
@ -0,0 +1,23 @@
|
||||
// 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
8
Areas/Identity/Pages/Account/ConfirmEmail.cshtml
Normal file
8
Areas/Identity/Pages/Account/ConfirmEmail.cshtml
Normal file
@ -0,0 +1,8 @@
|
||||
@page
|
||||
@model ConfirmEmailModel
|
||||
@{
|
||||
ViewData["Title"] = "Confirm email";
|
||||
}
|
||||
|
||||
<h1>@ViewData["Title"]</h1>
|
||||
<partial name="_StatusMessage" model="Model.StatusMessage" />
|
||||
52
Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs
Normal file
52
Areas/Identity/Pages/Account/ConfirmEmail.cshtml.cs
Normal file
@ -0,0 +1,52 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
8
Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml
Normal file
8
Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml
Normal file
@ -0,0 +1,8 @@
|
||||
@page
|
||||
@model ConfirmEmailChangeModel
|
||||
@{
|
||||
ViewData["Title"] = "Confirm email change";
|
||||
}
|
||||
|
||||
<h1>@ViewData["Title"]</h1>
|
||||
<partial name="_StatusMessage" model="Model.StatusMessage" />
|
||||
70
Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml.cs
Normal file
70
Areas/Identity/Pages/Account/ConfirmEmailChange.cshtml.cs
Normal file
@ -0,0 +1,70 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
33
Areas/Identity/Pages/Account/ExternalLogin.cshtml
Normal file
33
Areas/Identity/Pages/Account/ExternalLogin.cshtml
Normal file
@ -0,0 +1,33 @@
|
||||
@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" />
|
||||
}
|
||||
224
Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs
Normal file
224
Areas/Identity/Pages/Account/ExternalLogin.cshtml.cs
Normal file
@ -0,0 +1,224 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
25
Areas/Identity/Pages/Account/ForgotPassword.cshtml
Normal file
25
Areas/Identity/Pages/Account/ForgotPassword.cshtml
Normal file
@ -0,0 +1,25 @@
|
||||
@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" />
|
||||
}
|
||||
85
Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs
Normal file
85
Areas/Identity/Pages/Account/ForgotPassword.cshtml.cs
Normal file
@ -0,0 +1,85 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
@page
|
||||
@model ForgotPasswordConfirmation
|
||||
@{
|
||||
ViewData["Title"] = "Forgot password confirmation";
|
||||
}
|
||||
|
||||
<p>
|
||||
Please check your email to reset your password.
|
||||
</p>
|
||||
@ -0,0 +1,25 @@
|
||||
// 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Areas/Identity/Pages/Account/Lockout.cshtml
Normal file
10
Areas/Identity/Pages/Account/Lockout.cshtml
Normal file
@ -0,0 +1,10 @@
|
||||
@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>
|
||||
25
Areas/Identity/Pages/Account/Lockout.cshtml.cs
Normal file
25
Areas/Identity/Pages/Account/Lockout.cshtml.cs
Normal file
@ -0,0 +1,25 @@
|
||||
// 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
188
Areas/Identity/Pages/Account/Login.cshtml
Normal file
188
Areas/Identity/Pages/Account/Login.cshtml
Normal file
@ -0,0 +1,188 @@
|
||||
@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>
|
||||
}
|
||||
141
Areas/Identity/Pages/Account/Login.cshtml.cs
Normal file
141
Areas/Identity/Pages/Account/Login.cshtml.cs
Normal file
@ -0,0 +1,141 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
39
Areas/Identity/Pages/Account/LoginWith2fa.cshtml
Normal file
39
Areas/Identity/Pages/Account/LoginWith2fa.cshtml
Normal file
@ -0,0 +1,39 @@
|
||||
@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" />
|
||||
}
|
||||
132
Areas/Identity/Pages/Account/LoginWith2fa.cshtml.cs
Normal file
132
Areas/Identity/Pages/Account/LoginWith2fa.cshtml.cs
Normal file
@ -0,0 +1,132 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
Areas/Identity/Pages/Account/LoginWithRecoveryCode.cshtml
Normal file
29
Areas/Identity/Pages/Account/LoginWithRecoveryCode.cshtml
Normal file
@ -0,0 +1,29 @@
|
||||
@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" />
|
||||
}
|
||||
113
Areas/Identity/Pages/Account/LoginWithRecoveryCode.cshtml.cs
Normal file
113
Areas/Identity/Pages/Account/LoginWithRecoveryCode.cshtml.cs
Normal file
@ -0,0 +1,113 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
Areas/Identity/Pages/Account/Logout.cshtml
Normal file
21
Areas/Identity/Pages/Account/Logout.cshtml
Normal file
@ -0,0 +1,21 @@
|
||||
@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>
|
||||
43
Areas/Identity/Pages/Account/Logout.cshtml.cs
Normal file
43
Areas/Identity/Pages/Account/Logout.cshtml.cs
Normal file
@ -0,0 +1,43 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
36
Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml
Normal file
36
Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml
Normal file
@ -0,0 +1,36 @@
|
||||
@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" />
|
||||
}
|
||||
128
Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs
Normal file
128
Areas/Identity/Pages/Account/Manage/ChangePassword.cshtml.cs
Normal file
@ -0,0 +1,128 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
@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" />
|
||||
}
|
||||
104
Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml.cs
Normal file
104
Areas/Identity/Pages/Account/Manage/DeletePersonalData.cshtml.cs
Normal file
@ -0,0 +1,104 @@
|
||||
// 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("~/");
|
||||
}
|
||||
}
|
||||
}
|
||||
25
Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml
Normal file
25
Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml
Normal file
@ -0,0 +1,25 @@
|
||||
@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>
|
||||
70
Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs
Normal file
70
Areas/Identity/Pages/Account/Manage/Disable2fa.cshtml.cs
Normal file
@ -0,0 +1,70 @@
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
@page
|
||||
@model DownloadPersonalDataModel
|
||||
@{
|
||||
ViewData["Title"] = "Download Your Data";
|
||||
ViewData["ActivePage"] = ManageNavPages.PersonalData;
|
||||
}
|
||||
|
||||
<h3>@ViewData["Title"]</h3>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
44
Areas/Identity/Pages/Account/Manage/Email.cshtml
Normal file
44
Areas/Identity/Pages/Account/Manage/Email.cshtml
Normal file
@ -0,0 +1,44 @@
|
||||
@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" />
|
||||
}
|
||||
172
Areas/Identity/Pages/Account/Manage/Email.cshtml.cs
Normal file
172
Areas/Identity/Pages/Account/Manage/Email.cshtml.cs
Normal file
@ -0,0 +1,172 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
@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" />
|
||||
}
|
||||
@ -0,0 +1,189 @@
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml
Normal file
53
Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml
Normal file
@ -0,0 +1,53 @@
|
||||
@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>
|
||||
}
|
||||
142
Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml.cs
Normal file
142
Areas/Identity/Pages/Account/Manage/ExternalLogins.cshtml.cs
Normal file
@ -0,0 +1,142 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
@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>
|
||||
@ -0,0 +1,83 @@
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
30
Areas/Identity/Pages/Account/Manage/Index.cshtml
Normal file
30
Areas/Identity/Pages/Account/Manage/Index.cshtml
Normal file
@ -0,0 +1,30 @@
|
||||
@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" />
|
||||
}
|
||||
119
Areas/Identity/Pages/Account/Manage/Index.cshtml.cs
Normal file
119
Areas/Identity/Pages/Account/Manage/Index.cshtml.cs
Normal file
@ -0,0 +1,119 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
123
Areas/Identity/Pages/Account/Manage/ManageNavPages.cs
Normal file
123
Areas/Identity/Pages/Account/Manage/ManageNavPages.cs
Normal file
@ -0,0 +1,123 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
Areas/Identity/Pages/Account/Manage/PersonalData.cshtml
Normal file
27
Areas/Identity/Pages/Account/Manage/PersonalData.cshtml
Normal file
@ -0,0 +1,27 @@
|
||||
@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" />
|
||||
}
|
||||
37
Areas/Identity/Pages/Account/Manage/PersonalData.cshtml.cs
Normal file
37
Areas/Identity/Pages/Account/Manage/PersonalData.cshtml.cs
Normal file
@ -0,0 +1,37 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
@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>
|
||||
@ -0,0 +1,68 @@
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
35
Areas/Identity/Pages/Account/Manage/SetPassword.cshtml
Normal file
35
Areas/Identity/Pages/Account/Manage/SetPassword.cshtml
Normal file
@ -0,0 +1,35 @@
|
||||
@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" />
|
||||
}
|
||||
115
Areas/Identity/Pages/Account/Manage/SetPassword.cshtml.cs
Normal file
115
Areas/Identity/Pages/Account/Manage/SetPassword.cshtml.cs
Normal file
@ -0,0 +1,115 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
25
Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml
Normal file
25
Areas/Identity/Pages/Account/Manage/ShowRecoveryCodes.cshtml
Normal file
@ -0,0 +1,25 @@
|
||||
@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>
|
||||
@ -0,0 +1,47 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
@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" />
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
29
Areas/Identity/Pages/Account/Manage/_Layout.cshtml
Normal file
29
Areas/Identity/Pages/Account/Manage/_Layout.cshtml
Normal file
@ -0,0 +1,29 @@
|
||||
@{
|
||||
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)
|
||||
}
|
||||
15
Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml
Normal file
15
Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml
Normal file
@ -0,0 +1,15 @@
|
||||
@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>
|
||||
10
Areas/Identity/Pages/Account/Manage/_StatusMessage.cshtml
Normal file
10
Areas/Identity/Pages/Account/Manage/_StatusMessage.cshtml
Normal file
@ -0,0 +1,10 @@
|
||||
@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
Areas/Identity/Pages/Account/Manage/_ViewImports.cshtml
Normal file
1
Areas/Identity/Pages/Account/Manage/_ViewImports.cshtml
Normal file
@ -0,0 +1 @@
|
||||
@using PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
|
||||
67
Areas/Identity/Pages/Account/Register.cshtml
Normal file
67
Areas/Identity/Pages/Account/Register.cshtml
Normal file
@ -0,0 +1,67 @@
|
||||
@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" />
|
||||
}
|
||||
182
Areas/Identity/Pages/Account/Register.cshtml.cs
Normal file
182
Areas/Identity/Pages/Account/Register.cshtml.cs
Normal file
@ -0,0 +1,182 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
Areas/Identity/Pages/Account/RegisterConfirmation.cshtml
Normal file
23
Areas/Identity/Pages/Account/RegisterConfirmation.cshtml
Normal file
@ -0,0 +1,23 @@
|
||||
@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>
|
||||
}
|
||||
}
|
||||
|
||||
80
Areas/Identity/Pages/Account/RegisterConfirmation.cshtml.cs
Normal file
80
Areas/Identity/Pages/Account/RegisterConfirmation.cshtml.cs
Normal file
@ -0,0 +1,80 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml
Normal file
26
Areas/Identity/Pages/Account/ResendEmailConfirmation.cshtml
Normal file
@ -0,0 +1,26 @@
|
||||
@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" />
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
37
Areas/Identity/Pages/Account/ResetPassword.cshtml
Normal file
37
Areas/Identity/Pages/Account/ResetPassword.cshtml
Normal file
@ -0,0 +1,37 @@
|
||||
@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" />
|
||||
}
|
||||
118
Areas/Identity/Pages/Account/ResetPassword.cshtml.cs
Normal file
118
Areas/Identity/Pages/Account/ResetPassword.cshtml.cs
Normal file
@ -0,0 +1,118 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
@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>
|
||||
@ -0,0 +1,25 @@
|
||||
// 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
10
Areas/Identity/Pages/Account/_StatusMessage.cshtml
Normal file
10
Areas/Identity/Pages/Account/_StatusMessage.cshtml
Normal file
@ -0,0 +1,10 @@
|
||||
@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
Areas/Identity/Pages/Account/_ViewImports.cshtml
Normal file
1
Areas/Identity/Pages/Account/_ViewImports.cshtml
Normal file
@ -0,0 +1 @@
|
||||
@using PSTW_CentralSystem.Areas.Identity.Pages.Account
|
||||
23
Areas/Identity/Pages/Error.cshtml
Normal file
23
Areas/Identity/Pages/Error.cshtml
Normal file
@ -0,0 +1,23 @@
|
||||
@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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user