Minor fix

This commit is contained in:
Aiman Hafiz 2025-12-18 15:53:17 +08:00
parent 422ec9a6e1
commit 2a72067dd6
1580 changed files with 69 additions and 897481 deletions

View File

@ -1,5 +1,34 @@
# Work Log
## Monday, December 15, 2025
### Features & UI Updates
- **Profile Page**
- **New Screen (`profile_screen.dart`)**:
- Implemented a dedicated profile page displaying user information: Email, Company, Department, and Role.
- Integrated with `AuthService` to fetch user details using the session cookie.
- **Navigation**:
- Added a "Profile" tab to the `BottomNavBar` (Index 2).
- Registered `/profile` route in `main.dart`.
- Updated `NavBar` enum to support the profile screen context.
- **Login Screen (`login_screen.dart`)**
- **Visual Update**:
- Moved the application logo to the top of the screen and increased its size for better branding visibility.
- Restored and positioned the "Person" icon below the logo.
- **List Item Actions (Admin Screens)**
- **UI Consistency**:
- Standardized the "Edit" and "Delete" slideable actions across multiple admin screens:
- `Supplier`
- `Manufacturer`
- `Station`
- `Product`
- `Item`
- **Styling**:
- Adjusted padding and margins to ensure action buttons strictly match the height of the list item containers.
- Added rounded corners (`border-radius`) to the "Delete" action button to seamlessly match the rounded shape of the item cards.
## Friday, December 5, 2025
### Features & UI Updates
@ -151,4 +180,4 @@
- **Technician (`technician_to_invMaster.dart`)**: Added "Show" button for documents/pictures.
### Fixes
- Fixed a compilation error in `product_request_service.dart` (missing closing brace).
- Fixed a compilation error in `product_request_service.dart` (missing closing brace).

View File

@ -409,14 +409,16 @@ class _ItemScreenState extends State<ItemScreen> {
const SizedBox(width: 16),
Expanded(child: _buildDetailItem('Warranty Until', item['endWDate'] ?? 'N/A'))]),
const SizedBox(height: 12),
const Text('Location', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.black54)),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _buildDetailItem('Location', item['currentStation'] ?? 'N/A')),
Expanded(child: _buildDetailItem('User', item['currentUser'] ?? 'N/A')),
const SizedBox(width: 16),
Expanded(child: _buildDetailItem('Store', item['currentStore'] ?? 'N/A')),
const SizedBox(width: 16),
Expanded(child: _buildDetailItem('User', item['currentUser'] ?? 'N/A')),
Expanded(child: _buildDetailItem('Station', item['currentStation'] ?? 'N/A')),
],
),
],

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:inventory_system/services/product_service.dart';
import 'package:inventory_system/services/api_service.dart';
import 'package:inventory_system/screens/admin/product/product_form.dart';
import 'package:inventory_system/screens/bottom_nav_bar.dart';
import 'package:inventory_system/screens/nav_bar.dart';
@ -272,7 +273,7 @@ class _ProductScreenState extends State<ProductScreen> {
// 2. Construct the full URL
// We get the base URL from your environment variables
final String baseUrl = 'https://dev9.pstw.com.my';
final String baseUrl = ApiService.baseUrl;
final String fullImageUrl = (imagePath != null && imagePath.isNotEmpty) ? baseUrl + imagePath : '';
return Container(

View File

@ -233,16 +233,41 @@ class _StationScreenState extends State<StationScreen> {
contentPadding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 16),
title: Text(
station['stationName'] ?? 'No Name',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
station['stationName'] ?? 'No Name',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
station['departmentName'] ?? 'No Department',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
),
),
],
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
station['fullName'] ?? 'N/A',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
),
),
),
subtitle: Text(
station['departmentName'] ?? 'No Department'),
),
),
),

View File

@ -1,5 +0,0 @@
{
"version": 1,
"isRoot": true,
"tools": {}
}

View File

@ -1,63 +0,0 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain

View File

@ -1,366 +0,0 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# Ignore local publish test builds
publish-test/

View File

@ -1,16 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "msedge",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:8080",
"webRoot": "${workspaceFolder}"
}
]
}

View File

@ -1,997 +0,0 @@
<!DOCTYPE html>
<html dir="ltr" lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!-- Tell the browser to be responsive to screen width -->
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="keywords"
content="wrappixel, admin dashboard, html css dashboard, web dashboard, bootstrap 5 admin, bootstrap 5, css3 dashboard, bootstrap 5 dashboard, Matrix lite admin bootstrap 5 dashboard, frontend, responsive bootstrap 5 admin template, Matrix admin lite design, Matrix admin lite dashboard bootstrap 5 dashboard template" />
<meta name="description"
content="Matrix Admin Lite Free Version is powerful and clean admin dashboard template, inpired from Bootstrap Framework" />
<meta name="robots" content="noindex,nofollow" />
<title>PSTW Centralize Web System</title>
<!-- Favicon icon -->
<link rel="icon"
type="image/png"
sizes="16x16"
href="/assets/images/favicon.png" />
<!-- Custom CSS -->
<link rel="stylesheet" href="/assets/libs/select2/dist/css/select2.min.css" />
<link rel="stylesheet" href="/assets/libs/jquery-minicolors/jquery.minicolors.css" />
<link rel="stylesheet" href="/assets/libs/bootstrap-datepicker/dist/css/bootstrap-datepicker.min.css" />
<link rel="stylesheet" href="/assets/libs/quill/dist/quill.snow.css" />
<link href="/dist/css/style.min.css" rel="stylesheet" />
<link href="/lib/printjs/print.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.5/font/bootstrap-icons.css">
<!-- DataTables CSS-->
<link href="/lib/datatables/datatables.css" rel="stylesheet" />
<!-- Vue Js -->
<script src="/js/vue.global.js"></script>
<!-- QR Js -->
<script src="/lib/qrcode/qrcode.min.js"></script>
<!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
<!-- WARNING: Respond.js doesn't work if you view the page via file:// -->
<!--[if lt IE 9]>
<script src="https://oss.maxcdn.com/libs/html5shiv/3.7.0/html5shiv.js"></script>
<script src="https://oss.maxcdn.com/libs/respond.js/1.4.2/respond.min.js"></script>
<![endif]-->
</head>
<body>
<style>
.btn-teal {
background-color: #20c997; /* Teal color */
color: #ffffff; /* White text */
border: none; /* Remove border */
}
.btn-teal:hover {
background-color: #17a589; /* Darker teal on hover */
color: #ffffff;
}
.btn-teal:focus,
.btn-teal.focus {
box-shadow: 0 0 0 0.2rem rgba(32, 201, 151, 0.5); /* Teal shadow on focus */
}
</style>
<!-- ============================================================== -->
<!-- Preloader - style you can find in spinners.css -->
<!-- ============================================================== -->
<div b-lhtx71ohus id="preloader" class="preloader">
<div b-lhtx71ohus class="lds-ripple">
<div b-lhtx71ohus class="lds-pos"></div>
<div b-lhtx71ohus class="lds-pos"></div>
</div>
</div>
<div b-lhtx71ohus class="modal fade" id="loadingModal" data-bs-backdrop="static" tabindex="-1" aria-hidden="true" style="z-index: 1051;">
<div b-lhtx71ohus class="modal-dialog modal-dialog-centered">
<div b-lhtx71ohus class="modal-content">
<div b-lhtx71ohus class="modal-header">
<button b-lhtx71ohus type="button" class="closeModal" data-dismiss="modal" aria-label="Close">
<span b-lhtx71ohus aria-hidden="true">&times;</span>
</button>
</div>
<div b-lhtx71ohus class="modal-body text-center">
<div b-lhtx71ohus class="spinner-border text-primary" role="status">
<span b-lhtx71ohus class="visually-hidden">Loading...</span>
</div>
<p b-lhtx71ohus class="mt-3">Please wait while we process your request...</p>
</div>
</div>
</div>
</div>
<!-- ============================================================== -->
<!-- Main wrapper - style you can find in pages.scss -->
<!-- ============================================================== -->
<div b-lhtx71ohus id="main-wrapper"
data-layout="vertical"
data-navbarbg="skin5"
data-sidebartype="full"
data-sidebar-position="absolute"
data-header-position="absolute"
data-boxed-layout="full">
<!-- ============================================================== -->
<!-- Topbar header - style you can find in pages.scss -->
<!-- ============================================================== -->
<header b-lhtx71ohus class="topbar" data-navbarbg="skin5">
<nav b-lhtx71ohus class="navbar top-navbar navbar-expand-md navbar-dark">
<div b-lhtx71ohus class="navbar-header" data-logobg="skin5">
<!-- ============================================================== -->
<!-- Logo -->
<!-- ============================================================== -->
<a class="navbar-brand" href="/">
<!-- Logo icon -->
<b b-lhtx71ohus class="logo-icon ps-2">
<!--You can put here icon as well // <i class="wi wi-sunset"></i> //-->
<!-- Dark Logo icon -->
<img b-lhtx71ohus src="/assets/images/logo-icon.png"
alt="homepage"
class="light-logo"
width="25" />
</b>
<!--End Logo icon -->
<!-- Logo text -->
<span b-lhtx71ohus class="logo-text ms-2">
<!-- dark Logo text -->
<img b-lhtx71ohus src="/assets/images/logo-text.png"
alt="homepage"
class="light-logo" />
</span>
<!-- Logo icon -->
<!-- <b class="logo-icon"> -->
<!--You can put here icon as well // <i class="wi wi-sunset"></i> //-->
<!-- Dark Logo icon -->
<!-- <img src="/assets/images/logo-text.png" alt="homepage" class="light-logo" /> -->
<!-- </b> -->
<!--End Logo icon -->
</a>
<!-- ============================================================== -->
<!-- End Logo -->
<!-- ============================================================== -->
<!-- ============================================================== -->
<!-- Toggle which is visible on mobile only -->
<!-- ============================================================== -->
<a b-lhtx71ohus class="nav-toggler waves-effect waves-light d-block d-md-none"
href="javascript:void(0)">
<i b-lhtx71ohus class="ti-menu ti-close"></i>
</a>
</div>
<!-- ============================================================== -->
<!-- End Logo -->
<!-- ============================================================== -->
<div b-lhtx71ohus class="navbar-collapse collapse"
id="navbarSupportedContent"
data-navbarbg="skin5">
<!-- ============================================================== -->
<!-- toggle and nav items -->
<!-- ============================================================== -->
<ul b-lhtx71ohus class="navbar-nav float-start me-auto">
<li b-lhtx71ohus class="nav-item d-none d-lg-block">
<a b-lhtx71ohus class="nav-link sidebartoggler waves-effect waves-light"
href="javascript:void(0)"
data-sidebartype="mini-sidebar">
<i b-lhtx71ohus class="mdi mdi-menu font-24"></i>
</a>
</li>
<li b-lhtx71ohus class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li b-lhtx71ohus class="nav-item">
<a class="nav-link" href="/Home/Privacy">Privacy</a>
</li>
<!-- ============================================================== -->
<!-- create new -->
<!-- ============================================================== -->
<!--<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle"
href="#"
id="navbarDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
<span class="d-none d-md-block">
Create New <i class="fa fa-angle-down"></i>
</span>
<span class="d-block d-md-none">
<i class="fa fa-plus"></i>
</span>
</a>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a class="dropdown-item" href="#">Action</a></li>
<li><a class="dropdown-item" href="#">Another action</a></li>
<li><hr class="dropdown-divider" /></li>
<li>
<a class="dropdown-item" href="#">Something else here</a>
</li>
</ul>
</li> b-lhtx71ohus-->
<!-- ============================================================== -->
<!-- Search -->
<!-- ============================================================== -->
<!--<li class="nav-item search-box">
<a class="nav-link waves-effect waves-dark"
href="javascript:void(0)">
<i class="mdi mdi-magnify fs-4"></i>
</a>
<form class="app-search position-absolute">
<input type="text"
class="form-control"
placeholder="Search &amp; enter" />
<a class="srh-btn"><i class="mdi mdi-window-close"></i></a>
</form>
</li> b-lhtx71ohus-->
</ul>
<!-- ============================================================== -->
<!-- Right side toggle and nav items -->
<!-- ============================================================== -->
<ul b-lhtx71ohus class="navbar-nav float-end">
<!-- ============================================================== -->
<!-- Comment -->
<!-- ============================================================== -->
<li class="nav-item d-inline">
<a id="manage" class="nav-link" title="Manage" href="/Identity/Account/Manage">
Hello alim.aidrus@pstw.com.my!
</a>
</li>
<li class="nav-item d-inline">
<a id="logout" class="nav-link" href="javascript:void(0);" onclick="logout()">Logout</a>
</li>
<input name="__RequestVerificationToken" type="hidden" value="CfDJ8N0yQF8b8n9Ln4B-pzXVXnJmppvi5FipiNu_eLvsgZCtng4OcCdmUhrRhcaWR3ve9kmLHemvA3cZBTiTdB1xccBF_cS49wtAa4yO0M9suKrehJ_tgISJPpmk-RyXhkUgmV62uUZKHpZhJKg7cp1D9XcOoVuqHR4c6jnH2b7X3P7o5gMEPnB5bkcV7-E9nimb8g" /> <script>
function logout() {
const returnUrl = '/';
fetch('/Identity/Account/Logout', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `__RequestVerificationToken=${document.querySelector('input[name="__RequestVerificationToken"]').value}&returnUrl=${encodeURIComponent(returnUrl)}`
}).then(response => {
if (response.ok) {
window.location.href = returnUrl;
} else {
alert('Logout failed.');
}
}).catch(error => {
console.error('Logout error:', error);
});
}
</script>
<!-- ============================================================== -->
<!-- End Comment -->
<!-- ============================================================== -->
<!-- ============================================================== -->
<!-- Messages -->
<!-- ============================================================== -->
<!-- ============================================================== -->
<!-- End Messages -->
<!-- ============================================================== -->
<!-- ============================================================== -->
<!-- User profile and search -->
<!-- ============================================================== -->
<!-- ============================================================== -->
<!-- User profile and search -->
<!-- ============================================================== -->
</ul>
</div>
</nav>
</header>
<!-- ============================================================== -->
<!-- End Topbar header -->
<!-- ============================================================== -->
<!-- ============================================================== -->
<!-- Left Sidebar - style you can find in sidebar.scss -->
<!-- ============================================================== -->
<aside b-lhtx71ohus class="left-sidebar" data-sidebarbg="skin5">
<!-- Sidebar scroll-->
<div b-lhtx71ohus class="scroll-sidebar">
<!-- Sidebar navigation-->
<nav b-lhtx71ohus class="sidebar-nav">
<ul b-lhtx71ohus id="sidebarnav" class="pt-4">
<li b-lhtx71ohus class="sidebar-item">
<a b-lhtx71ohus class="sidebar-link has-arrow waves-effect waves-dark"
href="javascript:void(0)"
aria-expanded="false">
<i b-lhtx71ohus class="mdi mdi-receipt"></i><span b-lhtx71ohus class="hide-menu">Administrator </span>
</a>
<ul b-lhtx71ohus aria-expanded="false" class="collapse first-level">
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/Admin">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Admin Dashboard</span>
</a>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/Admin/ModuleAdmin">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Module Administration</span>
</a>
</li>
</ul>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a b-lhtx71ohus class="sidebar-link waves-effect waves-dark sidebar-link"
href="#"
aria-expanded="false">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Dashboard</span>
</a>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a b-lhtx71ohus class="sidebar-link has-arrow waves-effect waves-dark"
href="javascript:void(0)"
aria-expanded="false">
<i b-lhtx71ohus class="mdi mdi-receipt"></i><span b-lhtx71ohus class="hide-menu">Inventory </span>
</a>
<ul b-lhtx71ohus aria-expanded="false" class="collapse first-level">
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/Inventory/InventoryMaster/AdminDashboard">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Admin Dashboard</span>
</a>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/Inventory/InventoryMaster/ProductRegistration">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Product Registration</span>
</a>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/Inventory/InventoryMaster/ItemRegistration">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Item Registration</span>
</a>
</li>
</ul>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a b-lhtx71ohus class="sidebar-link has-arrow waves-effect waves-dark"
href="javascript:void(0)"
aria-expanded="false">
<i b-lhtx71ohus class="mdi mdi-receipt"></i><span b-lhtx71ohus class="hide-menu">Report</span>
</a>
<ul b-lhtx71ohus aria-expanded="false" class="collapse first-level">
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/Report/Reporting">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Admin Dashboard</span>
</a>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/Report/Reporting/InventoryReport">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Inventory Report</span>
</a>
</li>
<!--MMS-->
<li b-lhtx71ohus class="sidebar-item">
<a b-lhtx71ohus class="sidebar-link has-arrow waves-effect waves-dark"
href="javascript:void(0)" aria-expanded="false">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">MMS</span>
</a>
<ul b-lhtx71ohus aria-expanded="false" class="collapse first-level">
<!-- Marine subsection -->
<li b-lhtx71ohus class="sidebar-item">
<a b-lhtx71ohus class="sidebar-link has-arrow waves-effect waves-dark"
href="javascript:void(0)" aria-expanded="false">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Marine</span>
</a>
<ul b-lhtx71ohus aria-expanded="false" class="collapse second-level">
<!-- Tar Ball Sampling Form link -->
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark" aria-expanded="false" href="/MMS/Marine/TarBallForm">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Tarball Report</span>
</a>
</li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a b-lhtx71ohus class="sidebar-link has-arrow waves-effect waves-dark"
href="javascript:void(0)"
aria-expanded="false">
<i b-lhtx71ohus class="mdi mdi-receipt"></i><span b-lhtx71ohus class="hide-menu">User Overtime</span>
</a>
<ul b-lhtx71ohus aria-expanded="false" class="collapse first-level">
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/OTcalculate/Overtime/OtRegister">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">OT Register</span>
</a>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/OTcalculate/Overtime/OtRecords">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">OT Records</span>
</a>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/OTcalculate/Overtime/OtStatus">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">OT Status</span>
</a>
</li>
</ul>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a b-lhtx71ohus class="sidebar-link has-arrow waves-effect waves-dark"
href="javascript:void(0)"
aria-expanded="false">
<i b-lhtx71ohus class="mdi mdi-receipt"></i><span b-lhtx71ohus class="hide-menu">Overtime Approval</span>
</a>
<ul b-lhtx71ohus aria-expanded="false" class="collapse first-level">
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/OTcalculate/ApprovalDashboard/Approval">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Pending Approval</span>
</a>
</li>
</ul>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a b-lhtx71ohus class="sidebar-link has-arrow waves-effect waves-dark"
href="javascript:void(0)"
aria-expanded="false">
<i b-lhtx71ohus class="mdi mdi-receipt"></i><span b-lhtx71ohus class="hide-menu">HR Dashboard</span>
</a>
<ul b-lhtx71ohus aria-expanded="false" class="collapse first-level">
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/OTcalculate/HrDashboard/Settings">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Settings</span>
</a>
</li>
</ul>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a b-lhtx71ohus class="sidebar-link has-arrow waves-effect waves-dark"
href="javascript:void(0)"
aria-expanded="false">
<i b-lhtx71ohus class="mdi mdi-receipt"></i><span b-lhtx71ohus class="hide-menu">Rooms Booking</span>
</a>
<ul b-lhtx71ohus aria-expanded="false" class="collapse first-level">
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/Bookings/Bookings/Managers">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Assign</span>
</a>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/Bookings/Bookings">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">List</span>
</a>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/Bookings/Bookings/Calendar">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Calendar</span>
</a>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/Bookings/Bookings/Room">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Rooms</span>
</a>
</li>
</ul>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a b-lhtx71ohus class="sidebar-link has-arrow waves-effect waves-dark"
href="javascript:void(0)"
aria-expanded="false">
<i b-lhtx71ohus class="mdi mdi-receipt"></i><span b-lhtx71ohus class="hide-menu">IT Request Form</span>
</a>
<ul b-lhtx71ohus aria-expanded="false" class="collapse first-level">
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/IT/ApprovalDashboard/Admin">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Assignings</span>
</a>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/IT/ApprovalDashboard/Create">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Registeration</span>
</a>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/IT/ApprovalDashboard/MyRequests">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">My Requests</span>
</a>
</li>
<li b-lhtx71ohus class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link" aria-expanded="false" href="/IT/ApprovalDashboard/Approval">
<i b-lhtx71ohus class="mdi mdi-view-dashboard"></i><span b-lhtx71ohus class="hide-menu">Approval</span>
</a>
</li>
</ul>
</li>
<!-- <li class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link"
href="charts.html"
aria-expanded="false">
<i class="mdi mdi-chart-bar"></i><span class="hide-menu">Charts</span>
</a>
</li>
<li class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link"
href="widgets.html"
aria-expanded="false">
<i class="mdi mdi-chart-bubble"></i><span class="hide-menu">Widgets</span>
</a>
</li>
<li class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link"
href="tables.html"
aria-expanded="false">
<i class="mdi mdi-border-inside"></i><span class="hide-menu">Tables</span>
</a>
</li>
<li class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link"
href="grid.html"
aria-expanded="false">
<i class="mdi mdi-blur-linear"></i><span class="hide-menu">Full Width</span>
</a>
</li>
<li class="sidebar-item">
<a class="sidebar-link has-arrow waves-effect waves-dark"
href="javascript:void(0)"
aria-expanded="false">
<i class="mdi mdi-receipt"></i><span class="hide-menu">Forms </span>
</a>
<ul aria-expanded="false" class="collapse first-level">
<li class="sidebar-item">
<a href="form-basic.html" class="sidebar-link">
<i class="mdi mdi-note-outline"></i><span class="hide-menu"> Form Basic </span>
</a>
</li>
<li class="sidebar-item">
<a href="form-wizard.html" class="sidebar-link">
<i class="mdi mdi-note-plus"></i><span class="hide-menu"> Form Wizard </span>
</a>
</li>
</ul>
</li>
<li class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link"
href="pages-buttons.html"
aria-expanded="false">
<i class="mdi mdi-relative-scale"></i><span class="hide-menu">Buttons</span>
</a>
</li>
<li class="sidebar-item">
<a class="sidebar-link has-arrow waves-effect waves-dark"
href="javascript:void(0)"
aria-expanded="false">
<i class="mdi mdi-face"></i><span class="hide-menu">Icons </span>
</a>
<ul aria-expanded="false" class="collapse first-level">
<li class="sidebar-item">
<a href="icon-material.html" class="sidebar-link">
<i class="mdi mdi-emoticon"></i><span class="hide-menu"> Material Icons </span>
</a>
</li>
<li class="sidebar-item">
<a href="icon-fontawesome.html" class="sidebar-link">
<i class="mdi mdi-emoticon-cool"></i><span class="hide-menu"> Font Awesome Icons </span>
</a>
</li>
</ul>
</li>
<li class="sidebar-item">
<a class="sidebar-link waves-effect waves-dark sidebar-link"
href="pages-elements.html"
aria-expanded="false">
<i class="mdi mdi-pencil"></i><span class="hide-menu">Elements</span>
</a>
</li>
<li class="sidebar-item">
<a class="sidebar-link has-arrow waves-effect waves-dark"
href="javascript:void(0)"
aria-expanded="false">
<i class="mdi mdi-move-resize-variant"></i><span class="hide-menu">Addons </span>
</a>
<ul aria-expanded="false" class="collapse first-level">
<li class="sidebar-item">
<a href="index2.html" class="sidebar-link">
<i class="mdi mdi-view-dashboard"></i><span class="hide-menu"> Dashboard-2 </span>
</a>
</li>
<li class="sidebar-item">
<a href="pages-gallery.html" class="sidebar-link">
<i class="mdi mdi-multiplication-box"></i><span class="hide-menu"> Gallery </span>
</a>
</li>
<li class="sidebar-item">
<a href="pages-calendar.html" class="sidebar-link">
<i class="mdi mdi-calendar-check"></i><span class="hide-menu"> Calendar </span>
</a>
</li>
<li class="sidebar-item">
<a href="pages-invoice.html" class="sidebar-link">
<i class="mdi mdi-bulletin-board"></i><span class="hide-menu"> Invoice </span>
</a>
</li>
<li class="sidebar-item">
<a href="pages-chat.html" class="sidebar-link">
<i class="mdi mdi-message-outline"></i><span class="hide-menu"> Chat Option </span>
</a>
</li>
</ul>
</li>
<li class="sidebar-item">
<a class="sidebar-link has-arrow waves-effect waves-dark"
href="javascript:void(0)"
aria-expanded="false">
<i class="mdi mdi-account-key"></i><span class="hide-menu">Authentication </span>
</a>
<ul aria-expanded="false" class="collapse first-level">
<li class="sidebar-item">
<a href="authentication-login.html" class="sidebar-link">
<i class="mdi mdi-all-inclusive"></i><span class="hide-menu"> Login </span>
</a>
</li>
<li class="sidebar-item">
<a href="authentication-register.html" class="sidebar-link">
<i class="mdi mdi-all-inclusive"></i><span class="hide-menu"> Register </span>
</a>
</li>
</ul>
</li>
<li class="sidebar-item">
<a class="sidebar-link has-arrow waves-effect waves-dark"
href="javascript:void(0)"
aria-expanded="false">
<i class="mdi mdi-alert"></i><span class="hide-menu">Errors </span>
</a>
<ul aria-expanded="false" class="collapse first-level">
<li class="sidebar-item">
<a href="error-403.html" class="sidebar-link">
<i class="mdi mdi-alert-octagon"></i><span class="hide-menu"> Error 403 </span>
</a>
</li>
<li class="sidebar-item">
<a href="error-404.html" class="sidebar-link">
<i class="mdi mdi-alert-octagon"></i><span class="hide-menu"> Error 404 </span>
</a>
</li>
<li class="sidebar-item">
<a href="error-405.html" class="sidebar-link">
<i class="mdi mdi-alert-octagon"></i><span class="hide-menu"> Error 405 </span>
</a>
</li>
<li class="sidebar-item">
<a href="error-500.html" class="sidebar-link">
<i class="mdi mdi-alert-octagon"></i><span class="hide-menu"> Error 500 </span>
</a>
</li>
</ul>
</li>
<li class="sidebar-item p-3">
<a href="https://github.com/wrappixel/matrix-admin-bt5"
target="_blank"
class="
w-100
btn btn-cyan
d-flex
align-items-center
text-white
">
<i class="mdi mdi-cloud-download font-20 me-2"></i>Download
Free
</a>
</li> -->
</ul>
</nav>
<!-- End Sidebar navigation -->
</div>
<!-- End Sidebar scroll-->
</aside>
<!-- ============================================================== -->
<!-- End Left Sidebar - style you can find in sidebar.scss -->
<!-- ============================================================== -->
<!-- ============================================================== -->
<!-- Page wrapper -->
<!-- ============================================================== -->
<div b-lhtx71ohus class="page-wrapper">
<!-- ============================================================== -->
<!-- Bread crumb and right sidebar toggle -->
<!-- ============================================================== -->
<div b-lhtx71ohus class="page-breadcrumb">
<div b-lhtx71ohus class="row">
<div b-lhtx71ohus class="col-12 d-flex no-block align-items-center">
<h1 b-lhtx71ohus class="m-0 page-title">Dashboard</h1>
<div b-lhtx71ohus class="ms-auto text-end">
<nav b-lhtx71ohus aria-label="breadcrumb">
<ol b-lhtx71ohus class="breadcrumb">
<li b-lhtx71ohus class="breadcrumb-item"><a b-lhtx71ohus href="#">Home</a></li>
<li b-lhtx71ohus class="breadcrumb-item active" aria-current="page">
Library
</li>
</ol>
</nav>
</div>
</div>
</div>
</div>
<!-- ============================================================== -->
<!-- End Bread crumb and right sidebar toggle -->
<!-- ============================================================== -->
<!-- ============================================================== -->
<!-- Container fluid -->
<!-- ============================================================== -->
<div b-lhtx71ohus class="container-fluid">
<!-- ============================================================== -->
<!-- Start Page Content -->
<!-- ============================================================== -->
<div b-lhtx71ohus class="row my-1">
<div class="container" id="invAdmin">
<div class="row">
<div class="text-center">
<p><h1 class="display-4">Inventory Admin Dashboard</h1></p>
<p v-show="currentUserCompanyDept.departmentName"><h2 class="display-6">Store: {{ currentUserCompanyDept.departmentName }}</h2></p>
</div>
</div>
<div class="row">
<div class="col-6 col-md-6 col-lg-3">
<div class="card card-hover">
<a href="/Inventory/InventoryMaster/SupplierRegistration">
<div class="box bg-info text-center">
<h1 class="font-light text-white">
<i class="mdi mdi-truck"></i>
</h1>
<h6 class="text-white">Supplier</h6>
</div>
</a>
</div>
</div>
<div class="col-6 col-md-6 col-lg-3">
<div class="card card-hover">
<a href="/Inventory/InventoryMaster/ManifacturerRegistration">
<div class="box bg-warning text-center">
<h1 class="font-light text-white">
<i class="mdi mdi-factory"></i>
</h1>
<h6 class="text-white">Manufacturer</h6>
</div>
</a>
</div>
</div>
<div class="col-6 col-md-6 col-lg-3">
<a href="/Inventory/InventoryMaster/ProductRegistration">
<div class="card card-hover">
<div class="box bg-cyan text-center">
<h1 class="font-light text-white">
<i class="mdi mdi-checkbox-multiple-blank"></i>
</h1>
<h6 class="text-white">Product</h6>
</div>
</div>
</a>
</div>
<div class="col-6 col-md-6 col-lg-3">
<div class="card card-hover">
<a href="/Inventory/InventoryMaster/ItemRegistration">
<div class="box bg-success text-center">
<h1 class="font-light text-white">
<i class="mdi mdi-checkbox-multiple-blank-circle"></i>
</h1>
<h6 class="text-white">Item</h6>
</div>
</a>
</div>
</div>
<div class="col-6 col-md-6 col-lg-3">
<div class="card card-hover">
<a href="/Inventory/InventoryMaster/StationRegistration">
<div class="box bg-warning text-center">
<h1 class="font-light text-white">
<i class="mdi mdi-truck"></i>
</h1>
<h6 class="text-white">Station</h6>
</div>
</a>
</div>
</div>
<div class="col-6 col-md-6 col-lg-3">
<div class="card card-hover">
<a href="/Inventory/InventoryMaster/ItemMovement">
<div class="box bg-cyan text-center">
<h1 class="font-light text-white">
<i class="mdi mdi-factory"></i>
</h1>
<h6 class="text-white">Item Movement</h6>
</div>
</a>
</div>
</div>
<div class="col-6 col-md-6 col-lg-3">
<div class="card card-hover">
<a href="/Inventory/InventoryMaster/ItemRequestMaster">
<div class="box bg-info text-center">
<h1 class="font-light text-white">
<i class="mdi mdi-checkbox-multiple-blank"></i>
</h1>
<h6 class="text-white">Product Request</h6>
</div>
</a>
</div>
</div>
<div class="col-6 col-md-6 col-lg-3">
<div class="card card-hover">
<a href="/Inventory/InventoryMaster/QrMaster">
<div class="box bg-success text-center">
<h1 class="font-light text-white">
<i class="mdi mdi-checkbox-multiple-blank-circle"></i>
</h1>
<h6 class="text-white">Scan Items</h6>
</div>
</a>
</div>
</div>
</div>
<div class="row card">
<div class="card-header">
<h3 class="card-title">Inventory Report</h3>
</div>
<div class="card-body">
<div v-if="reportData">
<div class="row justify-content-center">
<div class="col-3">
<h4>Statistic</h4>
<p>Total Number of Item Registered: {{ reportData.itemCountRegistered }}</p>
<p>Total Number of Item Still in Stock: {{ reportData.itemCountStillInStock }}</p>
</div>
<div class="col-3">
<h4>Item Registered </h4>
<p>This Month: {{ reportData.itemCountRegisteredThisMonth }}</p>
<p>Last Month: {{ reportData.itemCountRegisteredLastMonth }}</p>
</div>
<div class="col-3">
<h4>Item Stock Out </h4>
<p>This Month: {{ reportData.itemCountStockOutThisMonth }}</p>
<p>Last Month: {{ reportData.itemCountStockOutLastMonth }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ============================================================== -->
<!-- End PAge Content -->
<!-- ============================================================== -->
<!-- ============================================================== -->
<!-- Right sidebar -->
<!-- ============================================================== -->
<!-- .right-sidebar -->
<!-- ============================================================== -->
<!-- End Right sidebar -->
<!-- ============================================================== -->
</div>
<!-- ============================================================== -->
<!-- End Container fluid -->
<!-- ============================================================== -->
<!-- ============================================================== -->
<!-- footer -->
<!-- ============================================================== -->
<footer b-lhtx71ohus class="footer text-center">
All Rights Reserved by PSTW. Designed and Developed by
<a b-lhtx71ohus href="https://www.wrappixel.com">WrapPixel</a>.
</footer>
<!-- ============================================================== -->
<!-- End footer -->
<!-- ============================================================== -->
</div>
<!-- ============================================================== -->
<!-- End Page wrapper -->
<!-- ============================================================== -->
</div>
<!-- ============================================================== -->
<!-- End Wrapper -->
<!-- ============================================================== -->
<!-- ============================================================== -->
<!-- All Jquery -->
<!-- ============================================================== -->
<script src="/assets/libs/jquery/dist/jquery.min.js"></script>
<script src="/dist/js/jquery-ui.min.js"></script>
<!-- VUE Multiselect-->
<script src="/lib/vue-multiselect/vue-multiselect.js"></script>
<link href="/lib/vue-multiselect/vue-multiselect.min.css" rel="stylesheet" />
<!-- VUE Date Picker-->
<link href="/lib/vue-datepicker/mainvuedate.css" rel="stylesheet" />
<script src="/lib/vue-datepicker/vue-datepicker.iife.js"></script>
<!-- Bootstrap tether Core JavaScript -->
<script src="/assets/libs/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<!-- slimscrollbar scrollbar JavaScript -->
<script src="/assets/libs/perfect-scrollbar/dist/perfect-scrollbar.jquery.min.js"></script>
<script src="/assets/extra-libs/sparkline/sparkline.js"></script>
<!--Wave Effects -->
<script src="/dist/js/waves.js"></script>
<!--Menu sidebar -->
<script src="/dist/js/sidebarmenu.js"></script>
<!--Custom JavaScript -->
<script src="/dist/js/custom.min.js"></script>
<!--Form JS-->
<script src="/assets/libs/inputmask/dist/min/jquery.inputmask.bundle.min.js"></script>
<script src="/dist/js/pages/mask/mask.init.js"></script>
<script src="/assets/libs/select2/dist/js/select2.full.min.js"></script>
<script src="/assets/libs/jquery-asColor/dist/jquery-asColor.min.js"></script>
<script src="/assets/libs/jquery-asGradient/dist/jquery-asGradient.js"></script>
<script src="/assets/libs/jquery-asColorPicker/dist/jquery-asColorPicker.min.js"></script>
<script src="/assets/libs/jquery-minicolors/jquery.minicolors.min.js"></script>
<script src="/assets/libs/bootstrap-datepicker/dist/js/bootstrap-datepicker.min.js"></script>
<script src="/assets/libs/quill/dist/quill.min.js"></script>
<script src="/lib/printjs/print.min.js"></script>
<script src="/lib/html2canvas/html2canvas.min.js"></script>
<script src="/lib/html2canvas/dom-to-image.min.js"></script>
<!-- Datatables JS-->
<script src="/lib/datatables/datatables.js"></script>
<script src="/lib/jquery-validation/dist/jquery.validate.min.js"></script>
<script src="/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script>
<script>
$(function () {
app.mount('#invAdmin');
$('.closeModal').on('click', function () {
// Show the modal with the ID 'addManufacturerModal'.
$('.modal').modal('hide');
});
});
const app = Vue.createApp({
data() {
return {
currentUser: null,
currentUserCompanyDept: {
departmentName: null,
departmentId: null
},
reportData: null,
}
},
mounted() {
this.fetchUser();
},
methods: {
async fetchUser() {
try {
const response = await fetch(`/IdentityAPI/GetUserInformation/`, {
method: 'POST',
});
if (response.ok) {
const data = await response.json();
this.currentUser = data?.userInfo || null;
const companyDeptData = await this.currentUser.department;
const userRole = this.currentUser.role;
if(userRole == "SuperAdmin" || userRole == "SystemAdmin"){
this.currentUserCompanyDept = {departmentId : 0, departmentName : "All"}
this.fetchInventoryReport(0);
}
else{
this.currentUserCompanyDept = companyDeptData;
this.fetchInventoryReport(companyDeptData.departmentId);
}
}
else {
console.error(`Failed to fetch user: ${response.statusText}`);
}
}
catch (error) {
console.error('There was a problem with the fetch operation:', error);
}
},
async fetchInventoryReport(deptId){
try {
const response = await fetch(`/InvMainAPI/GetInventoryReport/` + deptId, {
method: 'POST',
});
if (response.ok) {
const data = await response.json();
this.reportData = data;
}
else {
console.error(`Failed to fetch user: ${response.statusText}`);
}
}
catch (error) {
console.error('There was a problem with the fetch operation:', error);
}
},
},
});
</script>
</body>
</html>

View File

@ -1,93 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PSTW_CentralSystem.DBContext;
namespace PSTW_CentralSystem.Areas.Bookings.Controllers
{
[Area("Bookings")]
[Authorize] // require login for everything here
public class BookingsController : Controller
{
private readonly CentralSystemContext _db;
private readonly ILogger<BookingsController> _logger;
public BookingsController(CentralSystemContext db, ILogger<BookingsController> logger)
{
_db = db;
_logger = logger;
}
// ---------- helpers ----------
private int? GetCurrentUserId()
{
var idStr = User?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
return int.TryParse(idStr, out var id) ? id : (int?)null;
}
// DB-backed manager check (NO Identity roles here)
private Task<bool> IsManagerAsync()
{
var me = GetCurrentUserId();
if (me is null) return Task.FromResult(false);
return _db.BookingManager.AsNoTracking()
.AnyAsync(x => x.UserId == me.Value && x.IsActive);
}
private Task<bool> AnyManagersAsync()
=> _db.BookingManager.AsNoTracking().AnyAsync();
private async Task<IActionResult?> RequireManagerOrForbidAsync()
{
if (await IsManagerAsync()) return null;
return Forbid(); // or RedirectToAction(nameof(Index));
}
// ---------- pages ----------
public IActionResult Index() => View();
// Manager-only (rooms list/maintenance)
public async Task<IActionResult> Room()
{
var gate = await RequireManagerOrForbidAsync();
if (gate is not null) return gate;
return View();
}
// Manager-only (create/edit room)
public async Task<IActionResult> RoomsCreate()
{
var gate = await RequireManagerOrForbidAsync();
if (gate is not null) return gate;
return View();
}
// Everyone can view the calendar
public IActionResult Calendar() => View();
// Managers page:
// - Bootstrap: if no managers exist yet, allow any authenticated user to seed.
// - Otherwise: only managers.
public async Task<IActionResult> Managers()
{
if (!await AnyManagersAsync())
{
ViewBag.Bootstrap = true; // optional UI hint
return View();
}
var gate = await RequireManagerOrForbidAsync();
if (gate is not null) return gate;
ViewBag.Bootstrap = false;
return View();
}
// Create/Edit booking (JS loads data by id)
public IActionResult Create(int? id)
{
ViewBag.Id = id;
return View();
}
}
}

View File

@ -1,27 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PSTW_CentralSystem.Areas.Bookings.Models
{
/// <summary>
/// Dynamic list of users who have "manager" powers in the Room Booking module.
/// Keep it simple (global scope); you can extend with CompanyId/DepartmentId/RoomId later.
/// </summary>
[Table("booking_managers")]
public class BookingManager
{
[Key]
public int BookingManagerId { get; set; }
/// <summary>FK → aspnetusers(Id) (int)</summary>
[Required]
public int UserId { get; set; }
public bool IsActive { get; set; } = true;
[Required]
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
public int? CreatedByUserId { get; set; }
}
}

View File

@ -1,88 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PSTW_CentralSystem.Areas.Bookings.Models
{
public enum BookingStatus
{
Pending = 0,
Approved = 1,
Rejected = 2,
Cancelled = 3
}
[Table("bookings")]
public class Booking : IValidatableObject
{
[Key]
[Column("BookingId")]
public int BookingId { get; set; }
/// <summary>FK → aspnetusers(Id) (int)</summary>
[Required]
public int RequestedByUserId { get; set; }
/// <summary>If booking on behalf of someone else; else null.</summary>
public int? TargetUserId { get; set; }
/// <summary>Snapshot of org at submission time.</summary>
public int? DepartmentId { get; set; } // FK → departments(DepartmentId)
public int? CompanyId { get; set; } // FK → companies(CompanyId)
/// <summary>Room being booked.</summary>
[Required]
public int RoomId { get; set; } // FK → rooms(RoomId)
[Required, StringLength(150)]
public string Title { get; set; } = string.Empty;
[StringLength(300)]
public string? Purpose { get; set; }
/// <summary>Use UTC to avoid TZ headaches; map to DATETIME in MySQL.</summary>
[Required]
public DateTime StartUtc { get; set; }
[Required]
public DateTime EndUtc { get; set; }
[StringLength(500)]
public string? Note { get; set; }
[Required]
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
[Required]
public DateTime LastUpdatedUtc { get; set; } = DateTime.UtcNow;
[Required]
public BookingStatus CurrentStatus { get; set; } = BookingStatus.Pending;
// ---- validation ----
public IEnumerable<ValidationResult> Validate(ValidationContext _)
{
if (EndUtc <= StartUtc)
yield return new ValidationResult("End time must be after start time.",
new[] { nameof(EndUtc) });
if ((EndUtc - StartUtc).TotalMinutes < 10)
yield return new ValidationResult("Minimum booking duration is 10 minutes.",
new[] { nameof(StartUtc), nameof(EndUtc) });
}
}
[Table("rooms")]
public class Room
{
[Key] public int RoomId { get; set; }
[Required, StringLength(120)]
public string RoomName { get; set; } = string.Empty;
[StringLength(40)]
public string? LocationCode { get; set; }
public int? Capacity { get; set; }
public bool IsActive { get; set; } = true;
}
}

View File

@ -1,721 +0,0 @@
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
ViewData["Title"] = "Bookings Calendar";
Layout = "_Layout";
}
<style>
.calendar-wrap {
background: #fff;
border-radius: 14px;
box-shadow: 0 4px 15px rgba(0,0,0,.08);
padding: 16px;
}
.toolbar {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
flex-wrap: wrap;
}
.toolbar .left, .toolbar .right {
display: flex;
gap: 8px;
align-items: center;
}
.legend {
display: flex;
gap: 10px;
align-items: center;
font-size: 12px;
}
.legend span {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 3px;
margin-right: 4px;
vertical-align: middle;
}
.free {
background: #d9ead3
}
.partial {
background: #ffe599
}
.busy {
background: #f4cccc
}
.grid {
display: grid;
grid-template-columns: repeat(7,1fr);
gap: 6px
}
.dow {
text-align: center;
font-weight: 600;
padding: 6px 0;
color: #555
}
.cell {
min-height: 110px;
background: #f8f9fa;
border-radius: 10px;
padding: 8px;
display: flex;
flex-direction: column;
cursor: pointer
}
.cell.other-month {
opacity: .5
}
.cell .daynum {
font-weight: 700;
margin-bottom: 6px
}
.status-bar {
height: 6px;
border-radius: 4px;
margin-top: 6px;
background: #eee;
overflow: hidden
}
.status-bar > div {
height: 100%
}
.cell.free .status-bar > div {
background: #b7dfa6
}
.cell.partial .status-bar > div {
background: #ffd966
}
.cell.busy .status-bar > div {
background: #ea9999
}
.controls select {
border-radius: 8px;
font-size: 13px
}
.chip {
font-size: 11px;
padding: 2px 6px;
border-radius: 999px;
background: #e9ecef;
display: inline-block;
margin-top: auto;
width: max-content
}
/* ===== Day Board (separate header row above timeline) ===== */
.dayboard {
display: grid;
grid-template-rows: auto 1fr; /* header row, then scrollable body */
border: 1px solid #e9ecef;
border-radius: 10px;
overflow: hidden;
}
.db-head {
display: grid;
grid-template-columns: 90px 1fr; /* left spacer, right room headers */
background: #fff;
border-bottom: 1px solid #e9ecef;
}
.db-head-left {
background: #fafbfc;
border-right: 1px solid #e9ecef;
}
.db-head-right {
overflow: hidden;
}
/* no scroll; pure header row */
.col-header {
display: grid;
grid-auto-flow: column;
}
.col-header div {
padding: 8px 10px;
font-weight: 600;
border-right: 1px solid #f1f1f1;
white-space: nowrap;
color: #2d3748;
}
.db-body {
display: grid;
grid-template-columns: 90px 1fr; /* left time sheet, right bookings sheet */
height: 60vh; /* viewport height for scrolling; tweak if needed */
}
.db-time {
background: #fafbfc;
border-right: 1px solid #e9ecef;
overflow: hidden;
position: relative;
}
.db-time::-webkit-scrollbar {
display: none;
}
.time-slot {
height: 32px; /* SLOT_PX: must match JS */
border-bottom: 1px dashed #eee;
padding: 0 6px;
font-size: 12px;
color: #666;
display: flex;
align-items: center;
box-sizing: border-box;
}
.db-cols {
position: relative;
overflow: auto; /* the pane that scrolls bookings vertically */
}
.canvas { /* gives the bookings pane its content height */
position: relative;
}
.col-grid {
position: absolute;
inset: 0;
display: grid;
grid-auto-flow: column;
}
.col-grid .gcol {
border-right: 1px solid #f1f1f1;
position: relative;
}
.hline {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: #eee
}
.nowline {
position: absolute;
left: 0;
right: 0;
height: 2px;
background: #ff6;
z-index: 3
}
.booking {
position: absolute;
left: 6px;
right: 6px;
border-radius: 8px;
background: #d1fae5;
border: 1px solid #a7f3d0;
box-shadow: 0 2px 6px rgba(0,0,0,.06);
padding: 6px 8px;
font-size: 12px;
overflow: hidden
}
.booking .t {
font-weight: 700
}
.booking .meta {
font-size: 11px;
color: #555
}
</style>
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0"></h2>
<div class="d-flex gap-2">
<a asp-area="Bookings" asp-controller="Bookings" asp-action="Index" class="btn btn-outline-secondary">Back to List</a>
<a asp-area="Bookings" asp-controller="Bookings" asp-action="Create" class="btn btn-primary">Create New</a>
</div>
</div>
<div id="alerts"></div>
<div class="calendar-wrap">
<div class="toolbar">
<div class="left">
<button id="btnPrev" class="btn btn-sm btn-outline-secondary">◀</button>
<button id="btnToday" class="btn btn-sm btn-outline-secondary">Today</button>
<button id="btnNext" class="btn btn-sm btn-outline-secondary">▶</button>
<strong id="lblMonth" class="ms-2"></strong>
</div>
<div class="right controls">
<select id="ddlRoom" class="form-select form-select-sm" style="min-width:220px">
<option value="">All rooms</option>
</select>
<select id="ddlUser" class="form-select form-select-sm" style="min-width:220px">
<option value="">All users</option>
</select>
</div>
</div>
<div class="legend mb-2">
<span class="free"></span> Free
<span class="partial"></span> Partially booked
<span class="busy"></span> Busy
</div>
<div class="grid mb-2" id="dowRow"></div>
<div class="grid" id="calGrid"></div>
</div>
<!-- ===== Day Board Modal ===== -->
<div class="modal fade" id="dayBoardModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen-lg-down modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div class="board-toolbar w-100">
<h5 class="modal-title me-auto" id="boardTitle">Schedule</h5>
<button class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
<div class="modal-body">
<div class="dayboard">
<!-- Header row (room names ABOVE the timeline) -->
<div class="db-head">
<div class="db-head-left"></div>
<div class="db-head-right">
<div id="colsHeader" class="col-header"></div>
</div>
</div>
<!-- Body row (time sheet left, bookings right) -->
<div class="db-body">
<div class="db-time" id="timeCol"></div>
<div class="db-cols" id="colsScroll">
<div class="canvas" id="canvas">
<div class="col-grid" id="colGrid"></div>
<div class="nowline d-none" id="nowLine"></div>
</div>
</div>
</div>
</div>
<div class="text-muted mt-2" id="boardFiltersInfo" style="font-size:.9rem;"></div>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
(() => {
// --- Constants / API ---
const api = `${window.location.origin}/api/BookingsApi`;
const calendarScope = "calendar";
// --- Small helpers ---
const pad = n => String(n).padStart(2, "0");
function startOfMonth(d) { const x = new Date(d); x.setDate(1); x.setHours(0, 0, 0, 0); return x; }
function startOfWeek(d) { const x = new Date(d); const day = (x.getDay() + 7) % 7; x.setDate(x.getDate() - day); x.setHours(0, 0, 0, 0); return x; }
function addDays(d, n) { const x = new Date(d); x.setDate(x.getDate() + n); return x; }
function ymd(d) { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }
function toUtcIso(d) { const off = d.getTimezoneOffset(); return new Date(d.getTime() - off * 60000).toISOString(); }
function fmtLocal(d) { const x = new Date(d); return `${pad(x.getHours())}:${pad(x.getMinutes())}`; }
function esc(s) { return String(s ?? "").replace(/[&<>"']/g, m => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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>
}

View File

@ -1,541 +0,0 @@
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
Layout = "_Layout";
}
<style>
.booking-wrap {
background: var(--bs-body-bg);
}
.booking-card {
background: #fff;
border-radius: 16px;
box-shadow: 0 6px 24px rgba(0,0,0,.08);
border: 1px solid rgba(0,0,0,.06);
overflow: hidden;
}
.booking-header {
padding: 20px 24px;
background: linear-gradient(180deg, rgba(248,249,250,1) 0%, rgba(255,255,255,1) 100%);
border-bottom: 1px solid rgba(0,0,0,.06);
}
.booking-body {
padding: 24px;
}
.section-title {
font-size: 0.95rem;
font-weight: 700;
letter-spacing: .02em;
color: #4a5568;
text-transform: uppercase;
margin: 12px 0 14px;
}
.hint {
font-size: .85rem;
color: #6b7280;
}
.form-card {
background: #fafafa;
border: 1px dashed rgba(0,0,0,.08);
border-radius: 12px;
padding: 16px;
margin-bottom: 14px;
}
.actions-bar {
position: sticky;
bottom: 0;
z-index: 2;
background: rgba(255,255,255,.9);
backdrop-filter: saturate(180%) blur(6px);
border-top: 1px solid rgba(0,0,0,.06);
padding: 12px 16px;
}
.actions-bar .btn {
min-width: 110px;
}
.required-badge {
font-size: .7rem;
font-weight: 600;
padding: 2px 8px;
background: #eef2ff;
color: #3730a3;
border-radius: 999px;
margin-left: 8px;
vertical-align: middle;
}
</style>
<div class="booking-wrap container-fluid px-0">
<div class="booking-card">
<div class="booking-header d-flex align-items-center justify-content-between">
<h2 class="m-0">Create Booking</h2>
<div id="alerts" class="ms-3" style="min-height:38px;"></div>
</div>
<div class="booking-body">
<form id="bookingForm" class="row g-3">
<input type="hidden" id="Id" />
<!-- DETAILS -->
<div class="col-12">
<div class="section-title">Details</div>
<div class="form-card">
<div class="row g-3">
<div class="col-md-6">
<label for="Title" class="form-label">Title <span class="required-badge">Required</span></label>
<input id="Title" class="form-control" maxlength="150" placeholder="e.g., Weekly Project Sync" required />
<div class="hint mt-1">Max 150 characters.</div>
<div class="invalid-feedback">Title is required (max 150 chars).</div>
</div>
<div class="col-md-6">
<label for="RoomId" class="form-label">Room <span class="required-badge">Required</span></label>
<select id="RoomId" class="form-select" required>
<option value="">-- Select Room --</option>
</select>
<div class="hint mt-1">Only active rooms are shown.</div>
<div class="invalid-feedback">Please select a room.</div>
</div>
</div>
</div>
</div>
<!-- SCHEDULE -->
<div class="col-12">
<div class="section-title">Schedule</div>
<div class="form-card">
<div class="row g-3 align-items-end">
<div class="col-md-6">
<label for="StartUtc" class="form-label">Start <span class="required-badge">Required</span></label>
<input id="StartUtc" type="datetime-local" class="form-control" step="1800" required />
<div class="hint mt-1">Local time; saved as UTC. 30-minute slots (e.g., 10:30, 11:00).</div>
</div>
<div class="col-md-6">
<label for="EndUtc" class="form-label">End <span class="required-badge">Required</span></label>
<input id="EndUtc" type="datetime-local" class="form-control" step="1800" required />
<div class="hint mt-1">Must be the same date as Start and after Start.</div>
</div>
</div>
</div>
</div>
<!-- REQUESTER -->
<div class="col-12">
<div class="section-title">Requester</div>
<div class="form-card">
<div class="row g-3">
<div class="col-md-6">
<label for="RequestedByUserId" class="form-label">User <span class="required-badge">Required</span></label>
<select id="RequestedByUserId" class="form-select" required>
<option value="">-- Select User --</option>
</select>
<div class="hint mt-1">Well 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>
}

View File

@ -1,861 +0,0 @@
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@using System.Security.Claims
@{
ViewData["Title"] = "Bookings";
Layout = "_Layout";
var isMgr = User?.IsInRole("Manager") == true;
var idStr = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var meId = int.TryParse(idStr, out var tmp) ? tmp : 0;
}
<style>
.pagination-info {
color: #555;
}
.table-container {
background: #fff;
border-radius: 15px;
box-shadow: 0 4px 15px rgba(0,0,0,.1);
padding: 25px;
margin-bottom: 20px;
}
.table th, .table td {
vertical-align: middle;
text-align: center;
font-size: 14px;
}
.header-green {
background-color: #d9ead3 !important;
}
.header-blue {
background-color: #cfe2f3 !important;
}
.header-orange {
background-color: #fce5cd !important;
}
.btn, input.form-control, select.form-control {
border-radius: 10px;
font-size: 13px;
}
.table-responsive {
overflow-x: auto;
}
/* Filters card */
.filters-card {
background: #fff;
border-radius: 14px;
border: 1px solid rgba(0,0,0,.06);
box-shadow: 0 6px 18px rgba(0,0,0,.06);
padding: 16px 20px;
margin-bottom: 16px;
display: none;
}
.filters-grid {
display: grid;
grid-template-columns: repeat(12,1fr);
gap: 12px 16px;
align-items: end;
}
.filters-field {
grid-column: span 3;
}
.filters-field label {
display: block;
font-size: 12px;
font-weight: 600;
color: #555;
margin-bottom: 6px;
}
.filters-field .form-control, .filters-field .form-select {
height: 42px;
border-radius: 10px;
font-size: 14px;
}
.filters-actions {
grid-column: 1/-1;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.filters-actions .btn {
height: 42px;
padding: 0 16px;
border-radius: 10px;
font-weight: 600;
}
@@media (max-width:1200px) {
.filters-field {
grid-column: span 4;
}
}
@@media (max-width:768px) {
.filters-field {
grid-column: span 6;
}
}
@@media (max-width:576px) {
.filters-field {
grid-column: span 12;
}
}
/* Fixed layout so <colgroup> widths are honored */
.table-fixed {
table-layout: fixed;
}
/* Column widths via <colgroup> classes */
.col-title {
width: 28ch;
}
.col-notes {
width: 10ch;
}
.col-room {
width: 12ch;
}
.col-date {
width: 14ch;
}
.col-time {
width: 16ch;
}
.col-user {
width: 14ch;
}
.col-actions {
width: 150px;
min-width: 150px;
}
/* Long text cells */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-align: center;
}
.td-title, .td-notes {
text-align: center;
}
/* Compact actions */
.actions-col {
white-space: nowrap;
}
/* ===== Notes overlay ===== */
.note-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.35);
display: none;
align-items: center;
justify-content: center;
z-index: 1080;
padding: 20px;
}
.note-card {
background: #fff;
border-radius: 14px;
box-shadow: 0 10px 30px rgba(0,0,0,.2);
max-width: 520px;
width: 100%;
padding: 18px 18px 14px;
border: 1px solid rgba(0,0,0,.06);
}
.note-card h6 {
margin: 0 0 8px;
font-weight: 700;
}
.note-card .note-body {
white-space: pre-wrap;
text-align: left;
}
.icon-btn {
--size: 34px;
width: var(--size);
height: var(--size);
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid rgba(0,0,0,.1);
background: #fff;
margin: 0 3px;
}
.icon-btn:disabled {
opacity: .5;
cursor: not-allowed;
}
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<script>
window.__ctx = { isManager: @(isMgr ? "true" : "false"), meId: @meId };
</script>
<div id="app">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-0"></h2>
<div class="d-flex gap-2">
<button id="btnToggleFilters" type="button" class="btn btn-outline-secondary">Filters</button>
<a asp-area="Bookings" asp-controller="Bookings" asp-action="Create" class="btn btn-primary">Create New</a>
</div>
</div>
<div id="alerts"></div>
<!-- Status Tabs -->
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link" :class="{ active: activeStatus === 'Pending' }" href="#" @@click.prevent="setStatus('Pending')">
Pending <span class="badge bg-warning text-dark">{{ statusCounts.Pending }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" :class="{ active: activeStatus === 'Approved' }" href="#" @@click.prevent="setStatus('Approved')">
Approved <span class="badge bg-success">{{ statusCounts.Approved }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" :class="{ active: activeStatus === 'Rejected' }" href="#" @@click.prevent="setStatus('Rejected')">
Rejected <span class="badge bg-dark">{{ statusCounts.Rejected }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" :class="{ active: activeStatus === 'Cancelled' }" href="#" @@click.prevent="setStatus('Cancelled')">
Cancelled <span class="badge bg-danger">{{ statusCounts.Cancelled }}</span>
</a>
</li>
</ul>
<div class="table-responsive table-container">
<!-- Filters Panel -->
<div id="filtersCard" class="filters-card">
<div class="filters-grid">
<div class="filters-field">
<label for="fltFrom">From date</label>
<input id="fltFrom" type="date" class="form-control" placeholder="dd/mm/yyyy" />
</div>
<div class="filters-field">
<label for="fltTo">To date</label>
<input id="fltTo" type="date" class="form-control" placeholder="dd/mm/yyyy" />
</div>
<div class="filters-field" id="userFilterField" @(isMgr ? "" : "style='display:none'")>
<label for="fltUser">User</label>
<select id="fltUser" class="form-select">
<option value="">All users</option>
</select>
</div>
<div class="filters-actions">
<button id="btnApply" type="button" class="btn btn-primary">Apply</button>
<button id="btnClear" type="button" class="btn btn-outline-secondary">Clear</button>
</div>
</div>
</div>
<table class="table table-bordered table-striped align-middle table-fixed" id="bookingsTable">
<colgroup>
<col class="col-title">
<col class="col-notes">
<col class="col-room">
<col class="col-date"> <!-- new Date column -->
<col class="col-time"> <!-- new Time column -->
@if (isMgr)
{
<col class="col-user">
}
<col class="col-actions">
</colgroup>
<thead class="table-light">
<tr>
<th class="header-green">Title</th>
<th class="header-green">Notes</th>
<th class="header-blue">Room</th>
<th class="header-blue">Date</th>
<th class="header-blue">Time</th>
@if (isMgr)
{
<th class="header-blue">User</th>
}
<th class="header-orange">Actions</th>
</tr>
</thead>
<tbody>
<tr><td colspan="@(isMgr ? 7 : 6)" class="text-center">Loading…</td></tr>
</tbody>
</table>
<!-- Pager -->
<div id="pagerBar" class="d-flex justify-content-between align-items-center mt-2">
<div class="d-flex align-items-center">
<button id="btnPrev" class="btn btn-outline-secondary btn-sm me-2" disabled>&laquo; Prev</button>
<button id="btnNext" class="btn btn-outline-secondary btn-sm" disabled>Next &raquo;</button>
</div>
<div class="pagination-info small">
<span id="pageInfo">Page 1 of 1</span>
<span id="rangeInfo" class="ms-2">(Showing 00 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 => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[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>
}

View File

@ -1,227 +0,0 @@
@{
ViewData["Title"] = "Booking Managers";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<style>
.card {
border-radius: 14px;
box-shadow: 0 6px 18px rgba(0,0,0,.06);
border: 0;
}
.card-header {
background: #f9fbff;
border-bottom: 1px solid #eef2f7;
border-top-left-radius: 14px;
border-top-right-radius: 14px;
}
.it-list {
max-height: 320px;
overflow: auto;
border: 1px solid #eef2f7;
border-radius: 10px;
padding: 6px;
background: #fff;
}
.it-item {
display: flex;
align-items: center;
gap: .5rem;
padding: 6px 8px;
border-radius: 8px;
cursor: pointer;
}
.it-item:hover {
background: #f7f9fc;
}
.it-name {
font-weight: 600;
color: #334155;
}
.it-selected {
min-height: 48px;
border: 1px dashed #dbe3ef;
background: #fbfdff;
border-radius: 10px;
padding: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .28rem .5rem;
margin: 4px;
border-radius: 999px;
background: #eef2ff;
color: #3949ab;
font-weight: 600;
font-size: 12px;
}
.chip-x {
border: 0;
background: transparent;
color: #6b7280;
line-height: 1;
font-size: 16px;
padding: 0 2px;
cursor: pointer;
}
.chip-x:hover {
color: #111827;
}
.form-text {
font-size: 12px;
color: #6c757d;
}
</style>
<div id="mgrApp" style="max-width:1000px; margin:auto; font-size:13px;">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="m-0">Booking Managers</h5>
<div>
<button class="btn btn-outline-secondary btn-sm" @@click="loadAll" :disabled="busy">Refresh</button>
<button class="btn btn-primary btn-sm ms-2" @@click="save" :disabled="saving || busy">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
<div class="card-body">
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
<div class="text-muted mb-3">
<small>Select users who should act as <strong>Managers</strong> for the Room Booking module (approve/reject, manage rooms, etc.).</small>
</div>
<div class="row g-3 align-items-start">
<!-- LEFT: Search + Available users -->
<div class="col-md-7">
<div class="input-group input-group-sm mb-2">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" placeholder="Search users by name…" v-model.trim="q">
</div>
<div class="it-list">
<label v-for="u in filteredUsers" :key="'u-'+u.id" class="it-item">
<input type="checkbox" :value="u.id" v-model="managerIds" />
<span class="it-name">{{ u.name }}</span>
<small class="text-muted ms-1">({{ u.email }})</small>
</label>
<div v-if="!filteredUsers.length" class="text-muted small p-2">No users match your search.</div>
</div>
</div>
<!-- RIGHT: Selected chips -->
<div class="col-md-5">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong>Selected ({{ selectedUsers.length }})</strong>
<button class="btn btn-link btn-sm text-decoration-none"
@@click="managerIds = []"
:disabled="!selectedUsers.length">
Clear all
</button>
</div>
<div class="it-selected">
<span v-for="u in selectedUsers" :key="'sel-'+u.id" class="chip">
{{ u.name }}
<button class="chip-x" @@click="remove(u.id)" aria-label="Remove">&times;</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>

View File

@ -1,268 +0,0 @@
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{
ViewData["Title"] = "Rooms";
Layout = "_Layout";
}
<style>
.table-container {
background: #fff;
border-radius: 15px;
box-shadow: 0 4px 15px rgba(0,0,0,.1);
padding: 25px;
margin-bottom: 20px;
}
.table th, .table td {
vertical-align: middle;
text-align: center;
font-size: 14px
}
.header-blue {
background: #cfe2f3 !important
}
.header-green {
background: #d9ead3 !important
}
.header-orange {
background: #fce5cd !important
}
.btn, input.form-control, select.form-control {
border-radius: 10px;
font-size: 13px
}
.table-responsive {
overflow-x: auto
}
</style>
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="mb-3"></h2>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#roomModal" onclick="openCreate()">New Room</button>
</div>
<div id="alerts"></div>
<div class="table-responsive table-container">
<table class="table table-bordered table-striped align-middle" id="roomsTable">
<thead class="table-light">
<tr>
<th class="header-blue">Name</th>
<th class="header-blue">Location</th>
<th class="header-green">Capacity</th>
<th class="header-green">Active</th>
<th class="header-orange">Actions</th>
</tr>
</thead>
<tbody><tr><td colspan="5" class="text-center">Loading…</td></tr></tbody>
</table>
</div>
<!-- Modal -->
<div class="modal fade" id="roomModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content" style="border-radius:14px;">
<div class="modal-header">
<h5 class="modal-title" id="roomModalTitle">New Room</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="RoomId" />
<div class="mb-3">
<label class="form-label">Room Name</label>
<input id="RoomName" class="form-control" maxlength="120" required />
</div>
<div class="mb-3">
<label class="form-label">Location Code</label>
<input id="LocationCode" class="form-control" maxlength="40" />
</div>
<div class="mb-3">
<label class="form-label">Capacity</label>
<input id="Capacity" class="form-control" type="number" min="0" />
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="IsActive" checked />
<label class="form-check-label" for="IsActive">Active</label>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button class="btn btn-success" id="saveBtn" onclick="saveRoom()">Save</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
(() => {
// --- Constants / refs ---
const api = `${window.location.origin}/api/BookingsApi`;
const tbody = document.querySelector("#roomsTable tbody");
// ---------- UI helpers ----------
function showAlert(type, msg) {
document.getElementById("alerts").innerHTML = `
<div class="alert alert-${type} alert-dismissible fade show" role="alert">
${msg}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>`;
}
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>\"']/g, m =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;"
}[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>
}

View File

@ -1,47 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace PSTW_CentralSystem.Areas.IT.Controllers
{
[Area("IT")]
[Authorize]
public class ApprovalDashboardController : Controller
{
public IActionResult Approval()
{
return View(); // ~/Areas/IT/Views/ApprovalDashboard/Approval.cshtml
}
public IActionResult Create()
{
return View(); // ~/Areas/IT/Views/ApprovalDashboard/Create.cshtml
}
public IActionResult MyRequests()
{
return View(); // ~/Areas/IT/Views/ApprovalDashboard/MyRequests.cshtml
}
public IActionResult Admin()
{
return View(); // ~/Areas/IT/Views/ApprovalDashboard/MyRequests.cshtml
}
public IActionResult RequestReview(int statusId)
{
ViewBag.StatusId = statusId;
return View(); // ~/Areas/IT/Views/ApprovalDashboard/RequestReview.cshtml
}
public IActionResult SectionB()
{
return View();
}
public IActionResult Edit()
{
return View();
}
public IActionResult SectionBEdit()
{
return View();
}
}
}

View File

@ -1,21 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PSTW_CentralSystem.Areas.IT.Models
{
[Table("it_approval_flows")]
public class ItApprovalFlow
{
[Key]
public int ItApprovalFlowId { get; set; }
[MaxLength(200)]
public string FlowName { get; set; }
// approvers
public int HodUserId { get; set; }
public int GroupItHodUserId { get; set; }
public int FinHodUserId { get; set; }
public int MgmtUserId { get; set; }
}
}

View File

@ -1,56 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PSTW_CentralSystem.Areas.IT.Models
{
[Table("it_requests")]
public class ItRequest
{
[Key]
public int ItRequestId { get; set; }
public int UserId { get; set; } // FK -> aspnetusers.Id
// snapshot fields (taken at submission time)
[Required]
[MaxLength(200)]
public string StaffName { get; set; } = string.Empty;
[MaxLength(200)]
public string? CompanyName { get; set; }
[MaxLength(200)]
public string? DepartmentName { get; set; }
[MaxLength(200)]
public string? Designation { get; set; }
[MaxLength(200)]
public string? Location { get; set; }
[MaxLength(50)]
public string? EmploymentStatus { get; set; } // Permanent / Contract / Temp / New Staff
public DateTime? ContractEndDate { get; set; }
public DateTime RequiredDate { get; set; }
[MaxLength(20)]
public string? PhoneExt { get; set; }
public DateTime SubmitDate { get; set; }
// navigation
public ICollection<ItRequestHardware> Hardware { get; set; } = new List<ItRequestHardware>();
public ICollection<ItRequestEmail> Emails { get; set; } = new List<ItRequestEmail>();
public ICollection<ItRequestOsRequirement> OsRequirements { get; set; } = new List<ItRequestOsRequirement>();
public ICollection<ItRequestSoftware> Software { get; set; } = new List<ItRequestSoftware>();
public ICollection<ItRequestSharedPermission> SharedPermissions { get; set; } = new List<ItRequestSharedPermission>();
public DateTime? FirstSubmittedAt { get; set; } // when the request was first created
public DateTime? EditableUntil { get; set; } // FirstSubmittedAt + window
public bool IsLockedForEdit { get; set; }
}
}

View File

@ -1,36 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PSTW_CentralSystem.Areas.IT.Models
{
[Table("it_request_asset_info")]
public class ItRequestAssetInfo
{
public int Id { get; set; }
public int ItRequestId { get; set; }
public string? AssetNo { get; set; }
public string? MachineId { get; set; }
public string? IpAddress { get; set; }
public string? WiredMac { get; set; }
public string? WifiMac { get; set; }
public string? DialupAcc { get; set; }
public string? Remarks { get; set; }
public DateTime? LastEditedAt { get; set; }
public int? LastEditedByUserId { get; set; }
public string? LastEditedByName { get; set; }
public bool RequestorAccepted { get; set; }
public DateTime? RequestorAcceptedAt { get; set; }
public bool ItAccepted { get; set; }
public DateTime? ItAcceptedAt { get; set; }
public int? ItAcceptedByUserId { get; set; }
public string? ItAcceptedByName { get; set; }
public bool SectionBSent { get; set; } // default false
public DateTime? SectionBSentAt { get; set; }
}
}

View File

@ -1,24 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PSTW_CentralSystem.Areas.IT.Models
{
[Table("it_request_emails")]
public class ItRequestEmail
{
[Key]
public int Id { get; set; }
public int ItRequestId { get; set; }
[ForeignKey("ItRequestId")]
public ItRequest? Request { get; set; }
[MaxLength(50)]
public string? Purpose { get; set; } // New / Replacement / Additional
[MaxLength(200)]
public string? ProposedAddress { get; set; }
public string? Notes { get; set; }
}
}

View File

@ -1,26 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PSTW_CentralSystem.Areas.IT.Models
{
[Table("it_request_hardware")]
public class ItRequestHardware
{
[Key]
public int Id { get; set; }
public int ItRequestId { get; set; }
[ForeignKey("ItRequestId")]
public ItRequest? Request { get; set; }
[MaxLength(100)]
public string Category { get; set; } = ""; // Notebook, Desktop, etc.
[MaxLength(100)]
public string? Purpose { get; set; } // New / Replacement / Additional
public string? Justification { get; set; }
public string? OtherDescription { get; set; }
}
}

View File

@ -1,18 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PSTW_CentralSystem.Areas.IT.Models
{
[Table("it_request_osreqs")]
public class ItRequestOsRequirement
{
[Key]
public int Id { get; set; }
public int ItRequestId { get; set; }
[ForeignKey("ItRequestId")]
public ItRequest? Request { get; set; }
public string? RequirementText { get; set; }
}
}

View File

@ -1,24 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PSTW_CentralSystem.Areas.IT.Models
{
[Table("it_request_sharedperms")]
public class ItRequestSharedPermission
{
[Key]
public int Id { get; set; }
public int ItRequestId { get; set; }
[ForeignKey("ItRequestId")]
public ItRequest? Request { get; set; }
[MaxLength(100)]
public string? ShareName { get; set; }
public bool CanRead { get; set; }
public bool CanWrite { get; set; }
public bool CanDelete { get; set; }
public bool CanRemove { get; set; }
}
}

View File

@ -1,26 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PSTW_CentralSystem.Areas.IT.Models
{
[Table("it_request_software")]
public class ItRequestSoftware
{
[Key]
public int Id { get; set; }
public int ItRequestId { get; set; }
[ForeignKey("ItRequestId")]
public ItRequest? Request { get; set; }
[MaxLength(50)]
public string Bucket { get; set; } = ""; // General, Utility, Custom
[MaxLength(200)]
public string Name { get; set; } = "";
public string? OtherName { get; set; }
public string? Notes { get; set; }
}
}

View File

@ -1,37 +0,0 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PSTW_CentralSystem.Areas.IT.Models
{
[Table("it_request_status")]
public class ItRequestStatus
{
[Key]
public int StatusId { get; set; }
public int ItRequestId { get; set; }
[ForeignKey("ItRequestId")]
public ItRequest? Request { get; set; }
public int ItApprovalFlowId { get; set; }
[ForeignKey("ItApprovalFlowId")]
public ItApprovalFlow? Flow { get; set; }
// per-stage statuses
[MaxLength(20)] public string? HodStatus { get; set; }
[MaxLength(20)] public string? GitHodStatus { get; set; }
[MaxLength(20)] public string? FinHodStatus { get; set; }
[MaxLength(20)] public string? MgmtStatus { get; set; }
public DateTime? HodSubmitDate { get; set; }
public DateTime? GitHodSubmitDate { get; set; }
public DateTime? FinHodSubmitDate { get; set; }
public DateTime? MgmtSubmitDate { get; set; }
[MaxLength(20)]
public string? OverallStatus { get; set; } // Pending / Approved / Rejected
}
}

View File

@ -1,12 +0,0 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace PSTW_CentralSystem.Areas.IT.Models
{
[Table("it_team_members")]
public class ItTeamMember
{
public int Id { get; set; }
public int UserId { get; set; }
}
}

View File

@ -1,730 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using QuestPDF.Drawing;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace PSTW_CentralSystem.Areas.IT.Printing
{
public class ItRequestPdfService
{
public byte[] Generate(ItRequestReportModel m)
{
QuestPDF.Settings.License = LicenseType.Community;
return Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A4);
page.Margin(14);
page.DefaultTextStyle(x => x.FontSize(7));
page.Content().Column(col =>
{
col.Item().Element(e => HeaderStrip(e, m));
col.Item().PaddingTop(4).Text(
"This form is digitally generated. Submit to http://support.transwater.com.my");
// ===== SECTION A =====
col.Item().PaddingTop(4).Text("Section A").Bold().FontSize(7);
col.Item().Element(e => SectionA_IdentityEmployment(e, m));
// two-column layout (left = Hardware+OS+Software, right = Email+Internet+Shared+Copier)
col.Item().Table(t =>
{
t.ColumnsDefinition(cols =>
{
cols.RelativeColumn(1); // LEFT
cols.ConstantColumn(8); // gutter
cols.RelativeColumn(1); // RIGHT
});
// left cell (entire left stack)
t.Cell()
.Element(x => x.MinHeight(380)) // optional: set a floor so both look balanced
.Element(e => SectionA_HardwareOsSoftware(e, m));
// gutter cell
t.Cell().Text("");
// right cell (entire right stack)
t.Cell()
.Element(x => x.MinHeight(380)) // same floor as left
.Element(e => SectionA_RightPane_EmailInternetShared(e, m));
});
col.Item().Element(e => FormArrangementBlock(e, m));
col.Item().Element(e => SectionB_TwoBlocks(e, m));
});
});
}).GeneratePdf();
}
// ---------------- helpers & styles ----------------
#region HELPERS
static string Box(bool on) => on ? "☒" : "☐";
static string F(DateTime? dt) => dt.HasValue ? dt.Value.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture) : "";
static IContainer Cell(IContainer x) => x.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(4);
static IContainer CellHead(IContainer x) => x.Background(Colors.Grey.Lighten4).Border(1).BorderColor(Colors.Grey.Lighten2).Padding(4);
static IContainer CellMandatory(IContainer x) => x.Background(Colors.Grey.Lighten2).Border(1).BorderColor(Colors.Grey.Lighten2).Padding(4);
#endregion
// ================= HEADER (boxed like your screenshot) =================
void HeaderStrip(IContainer c, ItRequestReportModel m)
{
c.Border(1).Padding(0).Table(t =>
{
t.ColumnsDefinition(cols =>
{
cols.RelativeColumn(2); // LEFT: big title
cols.RelativeColumn(1); // RIGHT: meta table
});
// LEFT pane: centered title
t.Cell().BorderRight(1).Padding(8).MinHeight(10).AlignCenter().AlignMiddle().Column(left =>
{
left.Item().Text("I.T. REQUEST FORM").SemiBold().FontSize(14).AlignCenter();
left.Item().Text("(Group IT)").SemiBold().FontSize(14).AlignCenter();
});
// RIGHT pane: 2-column grid with borders
t.Cell().Padding(0).Element(x => RightMetaBox(x, m));
});
}
// draws the right-side 2-column table with boxed rows
void RightMetaBox(IContainer c, ItRequestReportModel m)
{
c.Table(t =>
{
t.ColumnsDefinition(cols =>
{
cols.RelativeColumn(1); // label
cols.RelativeColumn(1); // value
});
void Row(string label, string value, bool isLast = false)
{
// label cell (left)
t.Cell().BorderBottom(1).Padding(2).Text(label);
// value cell (right) — add vertical divider between label/value
t.Cell().BorderLeft(1).BorderBottom(1).Padding(0).AlignMiddle().Text(value ?? "");
}
Row("Document No.", m.DocumentNo);
Row("Effective Date", (m.EffectiveDate == default) ? "" : m.EffectiveDate.ToString("dd/MM/yyyy"));
Row("Rev. No", m.RevNo); // <- shows dynamic StatusId value
Row("Doc. Page No", m.DocPageNo); // last row still gets bottom border for boxed look
});
}
// ================= SECTION A =================
#region SECTION A Identity & Employment
// ================= SECTION A Identity & Employment (refined layout) =================
// SECTION A — Identity + Employment as ONE BLOCK (matches screenshot)
// ================= SECTION A Compact Unified Block (new refined layout) =================
void SectionA_IdentityEmployment(IContainer c, ItRequestReportModel m)
{
// helpers
var emp = (m.EmploymentStatus ?? "").Trim().ToLowerInvariant();
bool isPerm = emp == "permanent";
bool isContract = emp == "contract";
bool isTemp = emp == "temp" || emp == "temporary";
bool isNew = emp == "new staff" || emp == "new";
IContainer L(IContainer x) => x.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(4);
IContainer V(IContainer x) => x.Border(1).BorderColor(Colors.Grey.Lighten2).Padding(4);
c.Table(t =>
{
// 6-column consistent grid
t.ColumnsDefinition(cols =>
{
cols.RelativeColumn(1); // L1
cols.RelativeColumn(2); // V1
cols.RelativeColumn(1); // L2
cols.RelativeColumn(2); // V2
cols.RelativeColumn(1); // L3
cols.RelativeColumn(2); // V3
});
// === Row 1: Staff Name / Company / Div ===
t.Cell().Element(L).Text("Staff Name:");
t.Cell().Element(V).Text(m.StaffName ?? "");
t.Cell().Element(L).Text("Company:");
t.Cell().Element(V).Text(m.CompanyName ?? "");
t.Cell().Element(L).Text("Div/Dept:");
t.Cell().Element(V).Text(m.DepartmentName ?? "");
// === Row 2: Designation / Location ===
t.Cell().Element(L).Text("Designation:");
t.Cell().Element(V).Text(m.Designation ?? "");
t.Cell().Element(L).Text("Location:");
t.Cell().Element(V).Text(m.Location ?? "");
// fill the last two cells to maintain full-width grid
t.Cell().Element(L).Text("");
t.Cell().Element(V).Text("");
// === Row 3: Employment Status + Contract End Date beside it ===
t.Cell().Element(L).Text("Employment Status:");
t.Cell().ColumnSpan(3).Element(V).Row(r =>
{
r.Spacing(12);
r.ConstantItem(50).Text($"{Box(isPerm)} Permanent");
r.ConstantItem(50).Text($"{Box(isContract)} Contract");
r.ConstantItem(50).Text($"{Box(isTemp)} Temp");
r.ConstantItem(50).Text($"{Box(isNew)} New Staff");
});
t.Cell().Element(L).Text("If Temp / Contract End Date:");
t.Cell().Element(V).Text(F(m.ContractEndDate));
// === Row 4: Phone Ext + Required Date beside it ===
t.Cell().Element(L).Text("Phone Ext:");
t.Cell().Element(V).Text(m.PhoneExt ?? "");
t.Cell().Element(L).Text("Required Date:");
t.Cell().Element(V).Text(F(m.RequiredDate));
// keep remaining two cells empty to close grid
t.Cell().Element(L).Text("");
t.Cell().Element(V).Text("");
});
}
#endregion
#region SECTION A HardwareOsSoftware
// ===================== SECTION A HARDWARE + OS + SOFTWARE =====================
void SectionA_HardwareOsSoftware(IContainer c, ItRequestReportModel m)
{
bool HasSoft(string name) =>
m.Software.Any(s => (s.Name ?? "").Equals(name, StringComparison.InvariantCultureIgnoreCase));
var general = new[] { "MS Word", "MS Excel", "MS Outlook", "MS PowerPoint", "MS Access", "MS Project", "Acrobat Standard", "AutoCAD", "Worktop/ERP Login" };
var utility = new[] { "PDF Viewer", "7Zip", "AutoCAD Viewer", "Smart Draw" };
c.Table(t =>
{
t.ColumnsDefinition(cols =>
{
cols.RelativeColumn(1);
});
// --- HEADER: Hardware Requirements ---
t.Cell().Element(x => x.Background(Colors.Black).Padding(3))
.Text("Hardware Requirements").FontColor(Colors.White).Bold();
// --- Hardware Body ---
t.Cell().Border(1).Padding(1).Column(col =>
{
col.Spacing(5);
col.Item().Row(r =>
{
// Left column: Purpose + Justification
r.RelativeItem().Column(left =>
{
left.Item().Text($"{Box(m.HwPurposeNewRecruitment)} New Staff Recruitment");
left.Item().Text($"{Box(m.HwPurposeReplacement)} Replacement");
left.Item().Text($"{Box(m.HwPurposeAdditional)} Additional");
left.Item().PaddingTop(5).Text("Justification (for hardware change) :");
left.Item().Border(1).Height(40).Padding(4)
.Text(string.IsNullOrWhiteSpace(m.Justification) ? "" : m.Justification);
});
// Right column: Category selection
r.RelativeItem().Column(right =>
{
right.Item().Text("Select below:");
right.Item().Text($"{Box(m.HwDesktopAllIn)} Desktop (all inclusive)");
right.Item().Text($"{Box(m.HwNotebookAllIn)} Notebook (all inclusive)");
right.Item().Text($"{Box(m.HwDesktopOnly)} Desktop only");
right.Item().Text($"{Box(m.HwNotebookOnly)} Notebook only");
right.Item().Text($"{Box(m.HwNotebookBattery)} Notebook battery");
right.Item().Text($"{Box(m.HwPowerAdapter)} Power Adapter");
right.Item().Text($"{Box(m.HwMouse)} Computer Mouse");
right.Item().Text($"{Box(m.HwExternalHdd)} External Hard Drive");
right.Item().Text("Other (Specify):");
right.Item().Border(1).Height(18).Padding(3)
.Text(string.IsNullOrWhiteSpace(m.HwOtherText) ? "-" : m.HwOtherText);
});
});
});
// --- HEADER: OS Requirements ---
t.Cell().Element(x => x.Background(Colors.Black).Padding(3))
.Text("OS Requirements (Leave blank if no specific requirement)")
.FontColor(Colors.White).Bold();
// --- OS Body ---
t.Cell().Border(1).Padding(5).Column(col =>
{
col.Item().Text("Requirements:");
col.Item().Border(1).Height(30).Padding(4)
.Text(m.OsRequirements.Any() ? string.Join(Environment.NewLine, m.OsRequirements) : "-");
});
// --- HEADER: Software Requirements ---
t.Cell().Element(x => x.Background(Colors.Black).Padding(3))
.Text("Software Requirements").FontColor(Colors.White).Bold();
// --- Software Body (3 columns) ---
t.Cell().Border(1).Padding(5).Table(st =>
{
st.ColumnsDefinition(cols =>
{
cols.RelativeColumn(1); // General
cols.RelativeColumn(1); // Utility
cols.RelativeColumn(1); // Custom
});
// Headings
st.Header(h =>
{
h.Cell().Element(CellHead).Text("General Software");
h.Cell().Element(CellHead).Text("Utility Software");
h.Cell().Element(CellHead).Text("Custom Software");
});
int maxRows = Math.Max(general.Length, utility.Length);
for (int i = 0; i < maxRows; i++)
{
st.Cell().Element(Cell)
.Text(i < general.Length ? $"{Box(HasSoft(general[i]))} {general[i]}" : "");
st.Cell().Element(Cell)
.Text(i < utility.Length ? $"{Box(HasSoft(utility[i]))} {utility[i]}" : "");
if (i == 0)
{
st.Cell().Element(Cell).Text("Others (Specify) :");
}
else if (i == 1)
{
st.Cell().Element(Cell).Border(1).Height(15).Padding(3).Text("-");
}
else st.Cell().Element(Cell).Text("");
}
});
});
}
#endregion
#region SECTION A EmailInternetSharedperm
// ===================== SECTION A RIGHT PANE =====================
void SectionA_RightPane_EmailInternetShared(IContainer c, ItRequestReportModel m)
{
c.Column(col =>
{
// ===== Email =====
col.Item().Element(x => x.Background(Colors.Black).Padding(3))
.Text("Email").FontColor(Colors.White).Bold();
col.Item().Border(1).Padding(6).Column(cc =>
{
// single-line "Email Address:" with inline box
cc.Item().Row(r =>
{
r.ConstantItem(100).Text("Email Address:"); // label column width
r.RelativeItem().Border(1).Height(16).PaddingHorizontal(4).AlignMiddle()
.Text(m.Emails.Any() ? string.Join("; ", m.Emails) : "");
});
cc.Item().PaddingTop(4).Text("Arrangement Guide: FirstName&LastName or Name&Surname or Surname&Name.");
cc.Item().Text("Full name and short form/ initial is allowed to be used if the name is too long.");
cc.Item().Text("e.g. siti.nurhaliza, jackie.chan, ks.chan");
});
// ===== Internet Permissions =====
col.Item().Element(x => x.Background(Colors.Black).Padding(3))
.Text("Internet Permissions").FontColor(Colors.White).Bold();
col.Item().Border(1).Padding(0).Column(cc =>
{
cc.Item().BorderBottom(1).Padding(6).Text($"{Box(false)} Unlimited Internet Access");
cc.Item().BorderBottom(1).Padding(6).Text($"{Box(false)} Limited Internet Access");
cc.Item().BorderBottom(1).Padding(6).Text($"{Box(false)} PSI Instant Messenger");
cc.Item().BorderBottom(1).Padding(6).Text($"{Box(false)} VPN");
cc.Item().PaddingLeft(14).PaddingTop(4).Text("Justification for the Internet Permissions:");
cc.Item().Padding(6).Border(1).Height(20).Padding(4).Text("-");
});
// ===== Shared Permissions =====
col.Item().PaddingTop(0).Element(x => x.Background(Colors.Black).Padding(3))
.Text("Shared Permissions").FontColor(Colors.White).Bold();
col.Item().Border(1).Padding(0).Element(x => SharedPermissionsTable(x, m));
// ===== Copier =====
col.Item().PaddingTop(0).Element(x => x.Border(1).Padding(8)).Column(cc =>
{
cc.Item().Row(rr =>
{
rr.RelativeItem().Row(r1 =>
{
r1.RelativeItem().Text("Copier Scanner");
r1.RelativeItem().Text($"{Box(true)} Black");
r1.RelativeItem().Text($"{Box(false)} Color");
});
rr.RelativeItem().Row(r2 =>
{
r2.RelativeItem().Text("Copier Printing");
r2.RelativeItem().Text($"{Box(true)} Black");
r2.RelativeItem().Text($"{Box(false)} Color");
});
});
});
});
}
void SharedPermissionsTable(IContainer c, ItRequestReportModel m)
{
c.Table(t =>
{
t.ColumnsDefinition(cols =>
{
cols.ConstantColumn(18); // #
cols.RelativeColumn(6); // Share
cols.RelativeColumn(1); // Read
cols.RelativeColumn(1); // Write
cols.RelativeColumn(1); // Delete
cols.RelativeColumn(1); // Remove
});
// header band
t.Header(h =>
{
h.Cell().Element(CellHead).Text("");
h.Cell().Element(CellHead).Text("Shared Permissions");
h.Cell().Element(CellHead).Text("Read");
h.Cell().Element(CellHead).Text("Write");
h.Cell().Element(CellHead).Text("Delete");
h.Cell().Element(CellHead).Text("Remove");
});
var rows = m.SharedPerms.Select((sp, i) => new
{
Index = i + 1,
sp.Share,
sp.R,
sp.W,
sp.D,
sp.Remove
}).ToList();
foreach (var r in rows)
{
t.Cell().Element(Cell).Text(r.Index.ToString());
t.Cell().Element(Cell).Text(r.Share ?? "");
t.Cell().Element(Cell).Text(Box(r.R));
t.Cell().Element(Cell).Text(Box(r.W));
t.Cell().Element(Cell).Text(Box(r.D));
t.Cell().Element(Cell).Text(Box(r.Remove));
}
int start = rows.Count + 1; // no hardcoded row; if no data, start at 1
for (int i = start; i <= 6; i++)
{
t.Cell().Element(Cell).Text(i.ToString());
t.Cell().Element(Cell).Text("");
t.Cell().Element(Cell).Text("");
t.Cell().Element(Cell).Text("");
t.Cell().Element(Cell).Text("");
t.Cell().Element(Cell).Text("");
}
});
}
#endregion
#region SECTION A OS Requirements
// ===================== SECTION A Form Arrangement =====================
void FormArrangementBlock(IContainer c, ItRequestReportModel m)
{
// tiny helper: draws the vertical borders for each cell so it looks like 5 boxed panels
IContainer BoxCell(IContainer x, bool isLast) =>
x.BorderLeft(1)
.BorderRight(isLast ? 1 : 0) // last cell closes the right border
.Padding(6)
.MinHeight(45); // keep all equal; adjust to taste
c.Column(col =>
{
// italic guide line
col.Item()
.PaddingBottom(4)
.Text("Form Arrangement: User → HOD → IT HOD → FINANCE HOD → CEO/CFO/COO")
.Italic();
// OUTER frame
col.Item().Border(1).Element(frame =>
{
frame.Table(t =>
{
// 5 equal columns
t.ColumnsDefinition(cols =>
{
cols.RelativeColumn(1);
cols.RelativeColumn(1);
cols.RelativeColumn(1);
cols.RelativeColumn(1);
cols.RelativeColumn(1);
});
// local function to render a boxed panel
void Panel(int index0to4, string title, string name, string date)
{
bool last = index0to4 == 4;
t.Cell().Element(x => BoxCell(x, last)).Column(cc =>
{
cc.Item().Text(title);
// signature line
// Name / Date rows
cc.Item().PaddingTop(8).Row(r =>
{
r.ConstantItem(40).Text("Name :");
r.RelativeItem().Text(string.IsNullOrWhiteSpace(name) ? "" : name);
});
cc.Item().Row(r =>
{
r.ConstantItem(40).Text("Date :");
r.RelativeItem().Text(string.IsNullOrWhiteSpace(date) ? "" : date);
});
});
}
// build the 5 panels (plug in your resolved names/dates)
Panel(0, "Requested by:", m.RequestorName ?? "", F(m.SubmitDate) ?? "");
Panel(1, "Approved by HOD:", m.HodApprovedBy ?? "", F(m.HodSubmitDate) ?? "");
Panel(2, "Approved by Group IT HOD:", m.GitHodApprovedBy ?? "", F(m.GitHodSubmitDate) ?? "");
Panel(3, "Supported by Finance HOD:", m.FinHodApprovedBy ?? "", F(m.FinHodSubmitDate) ?? "");
Panel(4, "Reviewed & Approved by Management:", m.MgmtApprovedBy ?? "", F(m.MgmtSubmitDate) ?? "");
});
});
});
}
#endregion
#region SECTION A Approvals
void Approvals(IContainer c, ItRequestReportModel m)
{
c.Column(col =>
{
col.Item().Row(r =>
{
r.RelativeItem().Column(cc =>
{
cc.Item().Text("Requested by:");
cc.Item().PaddingTop(12).Text("Name : MANDATORY");
cc.Item().Row(rr =>
{
rr.ConstantItem(40).Text("Date :");
rr.RelativeItem().Border(1).Height(14).Text(F(m.SubmitDate));
});
});
r.RelativeItem().Column(cc =>
{
cc.Item().Text("Approved by HOD:");
cc.Item().PaddingTop(12).Text("Name :");
cc.Item().Row(rr =>
{
rr.ConstantItem(40).Text("Date :");
rr.RelativeItem().Border(1).Height(16).Text("");
});
});
});
col.Item().Row(r =>
{
r.RelativeItem().Column(cc =>
{
cc.Item().Text("Approved by Group IT HOD:");
cc.Item().PaddingTop(12).Text("Name :");
cc.Item().Row(rr =>
{
rr.ConstantItem(40).Text("Date :");
rr.RelativeItem().Border(1).Height(16).Text("");
});
});
r.RelativeItem().Column(cc =>
{
cc.Item().Text("Supported by Finance HOD:");
cc.Item().PaddingTop(12).Text("Name :");
cc.Item().Row(rr =>
{
rr.ConstantItem(40).Text("Date :");
rr.RelativeItem().Border(1).Height(16).Text("");
});
});
});
col.Item().Row(r =>
{
r.RelativeItem().Column(cc =>
{
cc.Item().Text("Reviewed & Approved by");
cc.Item().Text("Management:");
cc.Item().PaddingTop(12).Text("Name :");
cc.Item().Row(rr =>
{
rr.ConstantItem(40).Text("Date :");
rr.RelativeItem().Border(1).Height(16).Text("");
});
});
r.RelativeItem().Text("");
});
});
}
#endregion
// ================= SECTION B =================
#region SECTION B Asset Information/ Acknowledgment
void SectionB_TwoBlocks(IContainer c, ItRequestReportModel m)
{
c.Column(col =>
{
// Title line exactly like screenshot (italic note on same line)
col.Item().Text(text =>
{
text.Span("Section B ").Bold();
text.Span("(To be completed by IT Staff only)").Italic();
});
// ===== Block 1: Asset Information (left) + Remarks (right) =====
col.Item().PaddingTop(4).Border(1).Element(block1 =>
{
block1.Table(t =>
{
t.ColumnsDefinition(cols =>
{
cols.RelativeColumn(1);
cols.ConstantColumn(1); // skinny divider (we'll draw borders on cells)
cols.RelativeColumn(1);
});
// Header row with black bands
// Left header
t.Cell().Element(x => x.Background(Colors.Black).Padding(3).BorderRight(1))
.Text("Asset Information").FontColor(Colors.White).Bold();
// divider (invisible content, keeps structure)
t.Cell().BorderLeft(0).BorderRight(0).Text("");
// Right header
t.Cell().Element(x => x.Background(Colors.Black).Padding(3))
.Text("Remarks:-").FontColor(Colors.White).Bold();
// Content row (equal height because same table row)
// Left: asset info table
t.Cell().Element(x => x.BorderTop(1).BorderRight(1).Padding(0))
.Element(x => SectionB_AssetInfoTable(x, m));
// divider
t.Cell().Border(0).Text("");
// Right: remarks box
t.Cell().Element(x => x.BorderTop(1).Padding(6).MinHeight(108))
.Text(string.IsNullOrWhiteSpace(m.Remarks) ? "" : m.Remarks);
});
});
// ===== Block 2: Requestor Acknowledgement (left) + Completed By (right) =====
// ===== Block 2: Requestor Acknowledgement (left) + Completed By (right) =====
col.Item().PaddingTop(6).Border(1).Element(block2 =>
{
block2.Table(t =>
{
t.ColumnsDefinition(cols =>
{
cols.RelativeColumn(1);
cols.ConstantColumn(1); // divider
cols.RelativeColumn(1);
});
// Left pane (no signature line, compact height)
t.Cell().Element(x => x.Padding(8).BorderRight(1)).Column(cc =>
{
cc.Item().Text("Requestor Acknowledgement:").Bold();
cc.Item().PaddingTop(4).Row(r =>
{
r.ConstantItem(44).Text("Name:");
r.RelativeItem().Text(m.RequestorName ?? "");
r.ConstantItem(44).Text("Date:");
r.RelativeItem().Text(F(m.RequestorAcceptedAt));
});
});
// divider
t.Cell().Border(0).Text("");
// Right pane (no signature line)
t.Cell().Element(x => x.Padding(8)).Column(cc =>
{
cc.Item().Text("Completed by:").Bold();
cc.Item().PaddingTop(4).Row(r =>
{
r.ConstantItem(44).Text("Name:");
r.RelativeItem().Text(m.ItCompletedBy ?? "");
r.ConstantItem(44).Text("Date:");
r.RelativeItem().Text(F(m.ItAcceptedAt));
});
});
});
});
});
}
// Left-pane table used in Block 1 (matches your rows & borders)
void SectionB_AssetInfoTable(IContainer c, ItRequestReportModel m)
{
c.Table(t =>
{
t.ColumnsDefinition(cols =>
{
cols.RelativeColumn(2); // label
cols.RelativeColumn(2); // value box
});
void Row(string label, string value)
{
t.Cell().BorderBottom(1).Padding(6).Text(label);
t.Cell().BorderLeft(1).BorderBottom(1).Padding(3).Text(value ?? "");
}
// top row gets its own bottom borders; left cell also has right divider
Row("Asset No:", m.AssetNo);
Row("Machine ID:", m.MachineId);
Row("IP Add:", m.IpAddress);
Row("Wired Mac Add:", m.WiredMac);
Row("Wi-Fi Mac Add:", m.WifiMac);
Row("Dial-up Acc:", m.DialupAcc);
});
}
#endregion
}
}

View File

@ -1,79 +0,0 @@
namespace PSTW_CentralSystem.Areas.IT.Printing
{
public class ItRequestReportModel
{
// Header/meta
public string DocumentNo { get; set; } = "GITRF_01";
public string RevNo { get; set; } = "";
public string DocPageNo { get; set; } = "1 of 1";
public DateTime EffectiveDate { get; set; } = DateTime.Today;
// Section A Requestor snapshot
public string StaffName { get; set; } = "";
public string CompanyName { get; set; } = "";
public string DepartmentName { get; set; } = "";
public string Designation { get; set; } = "";
public string Location { get; set; } = "";
public string EmploymentStatus { get; set; } = "";
public DateTime? ContractEndDate { get; set; }
public DateTime RequiredDate { get; set; }
public string PhoneExt { get; set; } = "";
// Captured lists (kept for other sections)
public List<string> Hardware { get; set; } = new();
public string? Justification { get; set; }
public List<string> Emails { get; set; } = new();
public List<string> OsRequirements { get; set; } = new();
public List<(string Bucket, string Name, string? Other, string? Notes)> Software { get; set; } = new();
public List<(string Share, bool R, bool W, bool D, bool Remove)> SharedPerms { get; set; } = new();
// ===== NEW: Hardware purposes (left column) =====
public bool HwPurposeNewRecruitment { get; set; }
public bool HwPurposeReplacement { get; set; }
public bool HwPurposeAdditional { get; set; }
// ===== NEW: Hardware selections (right column) =====
public bool HwDesktopAllIn { get; set; }
public bool HwNotebookAllIn { get; set; }
public bool HwDesktopOnly { get; set; }
public bool HwNotebookOnly { get; set; }
public bool HwNotebookBattery { get; set; }
public bool HwPowerAdapter { get; set; }
public bool HwMouse { get; set; }
public bool HwExternalHdd { get; set; }
public string? HwOtherText { get; set; }
// Section B IT staff
public string AssetNo { get; set; } = "";
public string MachineId { get; set; } = "";
public string IpAddress { get; set; } = "";
public string WiredMac { get; set; } = "";
public string WifiMac { get; set; } = "";
public string DialupAcc { get; set; } = "";
public string Remarks { get; set; } = "";
// Acceptance
public string RequestorName { get; set; } = "";
public DateTime? RequestorAcceptedAt { get; set; }
public string ItCompletedBy { get; set; } = "";
public DateTime? ItAcceptedAt { get; set; }
// Status
public int ItRequestId { get; set; }
public int StatusId { get; set; }
public string OverallStatus { get; set; } = "";
public DateTime SubmitDate { get; set; }
// Approval Flow dates
public DateTime? HodSubmitDate { get; set; }
public DateTime? GitHodSubmitDate { get; set; }
public DateTime? FinHodSubmitDate { get; set; }
public DateTime? MgmtSubmitDate { get; set; }
// Approvers
public string HodApprovedBy { get; set; }
public string GitHodApprovedBy { get; set; }
public string FinHodApprovedBy { get; set; }
public string MgmtApprovedBy { get; set; }
}
}

View File

@ -1,473 +0,0 @@
@{
ViewData["Title"] = "IT Request Assignments";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<!-- Bootstrap Icons (kill this if you already include it in _Layout) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<style>
.card {
border-radius: 14px;
box-shadow: 0 6px 18px rgba(0,0,0,.06);
border: 0;
}
.card-header {
background: #f9fbff;
border-bottom: 1px solid #eef2f7;
border-top-left-radius: 14px;
border-top-right-radius: 14px;
}
.table-container {
background: #fff;
border-radius: 14px;
box-shadow: 0 6px 18px rgba(0,0,0,.06);
padding: 16px;
}
.form-text {
font-size: 12px;
color: #6c757d;
}
.w-110 {
width: 110px;
}
/* === IT Team nicer UI === */
.it-card .card-header {
background: #f8faff;
}
.it-list {
max-height: 280px;
overflow: auto;
border: 1px solid #eef2f7;
border-radius: 10px;
padding: 6px;
background: #fff;
}
.it-item {
display: flex;
align-items: center;
gap: .5rem;
padding: 6px 8px;
border-radius: 8px;
cursor: pointer;
}
.it-item:hover {
background: #f7f9fc;
}
.it-item input {
transform: translateY(1px);
}
.it-name {
font-weight: 600;
color: #334155;
}
.it-selected {
min-height: 48px;
border: 1px dashed #dbe3ef;
background: #fbfdff;
border-radius: 10px;
padding: 8px;
}
.chip {
display: inline-flex;
align-items: center;
gap: .4rem;
padding: .28rem .5rem;
margin: 4px;
border-radius: 999px;
background: #eef2ff;
color: #3949ab;
font-weight: 600;
font-size: 12px;
}
.chip-x {
border: 0;
background: transparent;
color: #6b7280;
line-height: 1;
font-size: 16px;
padding: 0 2px;
cursor: pointer;
}
.chip-x:hover {
color: #111827;
}
</style>
<div id="flowApp" style="max-width:1200px; margin:auto; font-size:13px;">
<!-- FLOWS CARD -->
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="m-0"></h5>
<div>
<button class="btn btn-primary btn-sm" @@click="openCreate">New Flow</button>
<button class="btn btn-outline-secondary btn-sm ms-2" @@click="load">Refresh</button>
</div>
</div>
<div class="card-body">
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
<div v-if="busy" class="alert alert-secondary py-2">Loading…</div>
<div class="table-container table-responsive">
<table class="table table-bordered table-sm table-striped align-middle text-center">
<thead class="table-light">
<tr>
<th class="w-110">Flow ID</th>
<th>Flow Name</th>
<th>HOD</th>
<th>Group IT HOD</th>
<th>FIN HOD</th>
<th>MGMT</th>
<th style="width:200px;">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="f in flows" :key="f.itApprovalFlowId">
<td>{{ f.itApprovalFlowId }}</td>
<td class="text-start">{{ f.flowName }}</td>
<!-- show id + resolved name so admins aren't guessing -->
<td>{{ f.hodUserId ? `${f.hodUserId} — ${resolveUserName(f.hodUserId)}` : '-' }}</td>
<td>{{ f.groupItHodUserId ? `${f.groupItHodUserId} — ${resolveUserName(f.groupItHodUserId)}` : '-' }}</td>
<td>{{ f.finHodUserId ? `${f.finHodUserId} — ${resolveUserName(f.finHodUserId)}` : '-' }}</td>
<td>{{ f.mgmtUserId ? `${f.mgmtUserId} — ${resolveUserName(f.mgmtUserId)}` : '-' }}</td>
<td>
<div class="btn-group">
<button class="btn btn-sm btn-outline-primary" @@click="openEdit(f)">Edit</button>
<button class="btn btn-sm btn-outline-danger" @@click="del(f)">Delete</button>
</div>
</td>
</tr>
<tr v-if="!flows.length && !busy">
<td colspan="7" class="text-muted">No flows yet. Click “New Flow”.</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-2 text-muted">
<small>
Heads up: <code>/ItRequestAPI/create</code> uses the first flow in the DB. Make sure one exists w/ approvers.
</small>
</div>
</div>
</div>
<!-- IT TEAM CARD -->
<div class="card mt-3 it-card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="m-0">IT Team Members</h6>
<button class="btn btn-sm btn-primary" @@click="saveItTeam" :disabled="savingTeam">
{{ savingTeam ? 'Saving…' : 'Save' }}
</button>
</div>
<div class="card-body">
<div class="text-muted mb-3">
<small>Select existing users to mark them as IT Team (they can edit Section B + do IT acceptance).</small>
</div>
<div class="row g-3 align-items-start">
<!-- LEFT: Search + Available -->
<div class="col-md-7">
<div class="input-group input-group-sm mb-2">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" placeholder="Search users by name…" v-model.trim="itSearch">
</div>
<div class="it-list">
<label v-for="u in filteredUsers" :key="'avail-'+u.id" class="it-item">
<input type="checkbox" :value="u.id" v-model="itTeamUserIds">
<span class="it-name">{{ u.name }}</span>
</label>
<div v-if="!filteredUsers.length" class="text-muted small p-2">
No users match your search.
</div>
</div>
</div>
<!-- RIGHT: Selected chips -->
<div class="col-md-5">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong>Selected ({{ selectedUsers.length }})</strong>
<button class="btn btn-link btn-sm text-decoration-none"
@@click="itTeamUserIds = []"
:disabled="!selectedUsers.length">
Clear all
</button>
</div>
<div class="it-selected">
<span v-for="u in selectedUsers" :key="'sel-'+u.id" class="chip">
{{ u.name }}
<button class="chip-x" @@click="removeIt(u.id)" aria-label="Remove">&times;</button>
</span>
<div v-if="!selectedUsers.length" class="text-muted small">Nobody selected yet.</div>
</div>
</div>
</div>
</div>
</div>
<!-- FLOW MODAL -->
<div class="modal fade" id="flowModal" tabindex="-1" aria-hidden="true" ref="modal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h6 class="modal-title">{{ form.itApprovalFlowId ? 'Edit Flow' : 'Create Flow' }}</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" @@click="closeModal"></button>
</div>
<div class="modal-body">
<div v-if="formError" class="alert alert-danger py-2">{{ formError }}</div>
<div class="mb-2">
<label class="form-label">Flow Name</label>
<input class="form-control form-control-sm" v-model.trim="form.flowName" placeholder="e.g., Default Flow">
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label">HOD Approver</label>
<select class="form-select form-select-sm" v-model.number="form.hodUserId">
<option :value="null">— None —</option>
<option v-for="u in users" :key="'hod-'+u.id" :value="u.id">{{ u.name }}</option>
</select>
<div class="form-text">Approver for HOD stage</div>
</div>
<div class="col-6">
<label class="form-label">Group IT HOD</label>
<select class="form-select form-select-sm" v-model.number="form.groupItHodUserId">
<option :value="null">— None —</option>
<option v-for="u in users" :key="'git-'+u.id" :value="u.id">{{ u.name }}</option>
</select>
<div class="form-text">Approver for Group IT HOD</div>
</div>
<div class="col-6">
<label class="form-label">Finance HOD</label>
<select class="form-select form-select-sm" v-model.number="form.finHodUserId">
<option :value="null">— None —</option>
<option v-for="u in users" :key="'fin-'+u.id" :value="u.id">{{ u.name }}</option>
</select>
<div class="form-text">Approver for Finance HOD</div>
</div>
<div class="col-6">
<label class="form-label">Management</label>
<select class="form-select form-select-sm" v-model.number="form.mgmtUserId">
<option :value="null">— None —</option>
<option v-for="u in users" :key="'mgmt-'+u.id" :value="u.id">{{ u.name }}</option>
</select>
<div class="form-text">Final management approver</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary btn-sm" data-bs-dismiss="modal" @@click="closeModal">Cancel</button>
<button class="btn btn-primary btn-sm" :disabled="saving" @@click="save">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</div>
</div>
</div>
<script>
const flowApp = Vue.createApp({
data() {
return {
flows: [],
users: [], // all users (name only for display)
itTeamUserIds: [], // selected user IDs for IT team
itSearch: '', // search term
busy: false,
error: null,
saving: false,
savingTeam: false,
formError: null,
form: {
itApprovalFlowId: null,
flowName: '',
hodUserId: null,
groupItHodUserId: null,
finHodUserId: null,
mgmtUserId: null
},
bsModal: null
};
},
computed: {
filteredUsers() {
const q = (this.itSearch || '').toLowerCase();
if (!q) return this.users;
return this.users.filter(u => (u.name || '').toLowerCase().includes(q));
},
selectedUsers() {
const set = new Set(this.itTeamUserIds);
return this.users.filter(u => set.has(u.id));
}
},
methods: {
async load() {
try {
this.busy = true; this.error = null;
const r = await fetch('/ItRequestAPI/flows');
if (!r.ok) throw new Error(`Load failed (${r.status})`);
const data = await r.json();
this.flows = Array.isArray(data)
? data.map(x => ({
itApprovalFlowId: x.itApprovalFlowId ?? x.ItApprovalFlowId,
flowName: x.flowName ?? x.FlowName,
hodUserId: x.hodUserId ?? x.HodUserId,
groupItHodUserId: x.groupItHodUserId ?? x.GroupItHodUserId,
finHodUserId: x.finHodUserId ?? x.FinHodUserId,
mgmtUserId: x.mgmtUserId ?? x.MgmtUserId
}))
: [];
} catch (e) {
this.error = e.message || 'Failed to load flows.';
} finally {
this.busy = false;
}
},
async loadUsers() {
const res = await fetch('/ItRequestAPI/users');
this.users = await res.json();
},
async loadItTeam() {
const r = await fetch('/ItRequestAPI/itTeam');
this.itTeamUserIds = await r.json(); // array<int>
},
resolveUserName(id) {
const u = this.users.find(x => x.id === id);
return u ? u.name : '(unknown)';
},
openCreate() {
this.formError = null;
this.form = { itApprovalFlowId: null, flowName: '', hodUserId: null, groupItHodUserId: null, finHodUserId: null, mgmtUserId: null };
this.showModal();
this.loadUsers();
},
openEdit(f) {
this.formError = null;
this.form = JSON.parse(JSON.stringify(f));
this.showModal();
this.loadUsers();
},
async save() {
try {
if (this.saving) return;
this.saving = true; this.formError = null;
if (!this.form.flowName || !this.form.flowName.trim()) {
this.formError = 'Flow name is required.'; this.saving = false; return;
}
const payload = {
flowName: this.form.flowName.trim(),
hodUserId: this.nullIfEmpty(this.form.hodUserId),
groupItHodUserId: this.nullIfEmpty(this.form.groupItHodUserId),
finHodUserId: this.nullIfEmpty(this.form.finHodUserId),
mgmtUserId: this.nullIfEmpty(this.form.mgmtUserId)
};
let res;
if (this.form.itApprovalFlowId) {
res = await fetch(`/ItRequestAPI/flows/${this.form.itApprovalFlowId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
} else {
res = await fetch('/ItRequestAPI/flows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
}
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.message || `Save failed (${res.status})`);
}
await this.load();
this.closeModal();
} catch (e) {
this.formError = e.message || 'Unable to save flow.';
} finally {
this.saving = false;
}
},
async del(f) {
if (!confirm(`Delete flow "${f.flowName}"?`)) return;
try {
const r = await fetch(`/ItRequestAPI/flows/${f.itApprovalFlowId}`, { method: 'DELETE' });
if (!r.ok) {
const j = await r.json().catch(() => ({}));
throw new Error(j.message || `Delete failed (${r.status})`);
}
await this.load();
} catch (e) {
alert(e.message || 'Delete failed.');
}
},
nullIfEmpty(v) {
if (v === undefined || v === null || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
},
showModal() {
const el = document.getElementById('flowModal');
this.bsModal = new bootstrap.Modal(el);
this.bsModal.show();
},
closeModal() {
if (this.bsModal) this.bsModal.hide();
},
removeIt(uid) {
this.itTeamUserIds = this.itTeamUserIds.filter(id => id !== uid);
},
async saveItTeam() {
try {
this.savingTeam = true;
const r = await fetch('/ItRequestAPI/itTeam', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userIds: this.itTeamUserIds })
});
if (!r.ok) {
const j = await r.json().catch(() => ({}));
throw new Error(j.message || `Save failed (${r.status})`);
}
alert('IT Team updated.');
} catch (e) {
alert(e.message || 'Failed to update IT Team.');
} finally {
this.savingTeam = false;
}
}
},
async mounted() {
await Promise.all([this.load(), this.loadUsers(), this.loadItTeam()]);
}
});
flowApp.mount('#flowApp');
</script>

View File

@ -1,475 +0,0 @@
@{
ViewData["Title"] = "IT Request Approval Board";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<style>
.table-container {
background: #fff;
border-radius: 14px;
box-shadow: 0 6px 18px rgba(0,0,0,.06);
padding: 16px;
margin-bottom: 16px;
table-layout: fixed;
width: 100%;
}
.table-container th,
.table-container td {
word-wrap: break-word;
vertical-align: middle;
}
.filters .form-label {
font-size: 12px;
margin-bottom: 4px;
}
.nav-tabs .badge {
margin-left: 6px;
}
.status-badges .badge + .badge {
margin-left: 4px;
}
.chip {
border: 1px solid #e5e7eb;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
background: #f9fafb;
}
.section-title {
font-weight: 800;
margin: 18px 0 10px;
}
</style>
<div id="app" style="max-width:1300px; margin:auto; font-size:13px;">
<h3 class="mb-2 fw-bold"></h3>
<p class="text-muted mb-3" style="margin-top:-6px;">Manage approvals and track Section B progress by month.</p>
<!-- Filters (shared by both tables) -->
<div class="row mb-3 align-items-end filters">
<div class="col-md-auto me-3">
<label class="form-label">Month</label>
<select class="form-control form-control-sm" v-model="selectedMonth" @@change="onPeriodChange">
<option v-for="(m,i) in months" :key="i" :value="i+1">{{ m }}</option>
</select>
</div>
<div class="col-md-auto">
<label class="form-label">Year</label>
<select class="form-control form-control-sm" v-model="selectedYear" @@change="onPeriodChange">
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
</select>
</div>
</div>
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
<div v-if="busy" class="alert alert-secondary py-2">Loading…</div>
<!-- ========================= -->
<!-- TABLE 1: Approvals Board -->
<!-- ========================= -->
<template v-if="isApprover">
<h5 class="section-title">Approvals</h5>
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link" :class="{ active: activeTab==='pending' }" href="#" @@click.prevent="switchTab('pending')">
Pending <span class="badge bg-warning text-dark">{{ pendingActionsCount }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" :class="{ active: activeTab==='completed' }" href="#" @@click.prevent="switchTab('completed')">
Completed <span class="badge bg-info">{{ completedActionsCount }}</span>
</a>
</li>
</ul>
<div class="table-container table-responsive">
<table class="table table-bordered table-sm table-striped align-middle text-center">
<thead class="table-light">
<tr>
<th>Staff Name</th>
<th>Department</th>
<th>Date Submitted</th>
<th>Stage / Role</th>
<th>Your Status</th>
<th style="width:260px;">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="row in paginatedData" :key="row.statusId">
<td>{{ row.staffName }}</td>
<td>{{ row.departmentName }}</td>
<td>{{ formatDate(row.submitDate) }}</td>
<td><span class="badge bg-light text-dark">{{ row.role }}</span></td>
<td class="status-badges">
<span :class="getStatusBadgeClass(row.currentUserStatus)">{{ row.currentUserStatus }}</span>
<span v-if="row.isOverallRejected && !['Approved','Rejected'].includes(row.currentUserStatus)" class="badge bg-danger">Rejected earlier</span>
</td>
<td>
<div class="d-flex justify-content-center align-items-center">
<template v-if="activeTab==='pending'">
<button v-if="row.canApprove" class="btn btn-success btn-sm me-1" @@click="updateStatus(row.statusId,'Approved')" :disabled="busy">Approve</button>
<button v-if="row.canApprove" class="btn btn-danger btn-sm me-1" @@click="updateStatus(row.statusId,'Rejected')" :disabled="busy">Reject</button>
<!-- removed 'awaiting previous stage' badge entirely -->
</template>
<button class="btn btn-primary btn-sm" @@click="viewRequest(row.statusId)">View</button>
</div>
</td>
</tr>
<tr v-if="!paginatedData.length"><td colspan="6" class="text-muted">No {{ activeTab }} requests</td></tr>
</tbody>
</table>
<!-- Pagination (Approvals) -->
<div class="d-flex justify-content-between align-items-center mt-2" v-if="filteredData.length">
<small class="text-muted">
Showing {{ (currentPage-1)*itemsPerPage + 1 }} {{ Math.min(currentPage*itemsPerPage, filteredData.length) }} of {{ filteredData.length }}
</small>
<div class="btn-group">
<button class="btn btn-outline-secondary btn-sm" :disabled="currentPage===1" @@click="currentPage--">Prev</button>
<button class="btn btn-outline-secondary btn-sm" :disabled="currentPage*itemsPerPage>=filteredData.length" @@click="currentPage++">Next</button>
</div>
</div>
</div>
</template>
<!-- ========================= -->
<!-- TABLE 2: Section B Board -->
<!-- ========================= -->
<template v-if="isItMember">
<h5 class="section-title">Section B</h5>
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link" :class="{ active: activeSbTab==='draft' }" href="#" @@click.prevent="switchSbTab('draft')">
Draft <span class="badge bg-info text-dark">{{ sbCountDraft }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" :class="{ active: activeSbTab==='pending' }" href="#" @@click.prevent="switchSbTab('pending')">
Pending <span class="badge bg-secondary">{{ sbCountPending }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" :class="{ active: activeSbTab==='awaiting' }" href="#" @@click.prevent="switchSbTab('awaiting')">
Awaiting <span class="badge bg-warning text-dark">{{ sbCountAwaiting }}</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" :class="{ active: activeSbTab==='complete' }" href="#" @@click.prevent="switchSbTab('complete')">
Complete <span class="badge bg-success">{{ sbCountComplete }}</span>
</a>
</li>
</ul>
<div class="table-container table-responsive">
<table class="table table-bordered table-sm table-striped align-middle text-center">
<thead class="table-light">
<tr>
<th>Staff Name</th>
<th>Department</th>
<th>Approved On</th>
<th>Section B Stage</th>
<th style="width:360px;">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="r in sbPaginated" :key="r.statusId">
<td>{{ r.staffName }}</td>
<td>{{ r.departmentName }}</td>
<td>{{ r.approvedAt ? formatDate(r.approvedAt) : '-' }}</td>
<td>
<span class="badge" :class="stageBadge(r.stage).cls">{{ stageBadge(r.stage).text }}</span>
</td>
<td>
<div class="d-flex justify-content-center align-items-center flex-wrap" style="gap:6px;">
<!-- 1⃣ Pending -->
<button v-if="r.stage==='PENDING'"
class="btn btn-outline-dark btn-sm"
@@click="openSectionB(r.statusId)">
Start Section B
</button>
<!-- DRAFT -->
<button v-if="r.stage==='DRAFT'"
class="btn btn-outline-primary btn-sm"
@@click="openSectionBEdit(r.statusId)">
Continue
</button>
<!-- AWAITING -->
<button v-if="r.stage==='AWAITING' && !r.sb.itAccepted"
class="btn btn-success btn-sm"
@@click="acceptIt(r.statusId)">
Accept
</button>
<button v-if="r.stage==='AWAITING' && r.sb.itAccepted"
class="btn btn-primary btn-sm"
@@click="openSectionB(r.statusId)">
Review Section B
</button>
<!-- 4⃣ Complete -->
<button v-if="r.stage==='COMPLETE'"
class="btn btn-primary btn-sm"
@@click="openSectionB(r.statusId)">
View
</button>
<button v-if="r.stage==='COMPLETE'"
class="btn btn-outline-secondary btn-sm"
@@click="downloadPdf(r.statusId)">
PDF
</button>
</div>
</td>
</tr>
<tr v-if="!busy && sbFiltered.length===0">
<td colspan="5" class="text-muted text-center"><i class="bi bi-inboxes"></i> No Section B items in this tab</td>
</tr>
</tbody>
</table>
<!-- Pagination (Section B) -->
<div class="d-flex justify-content-between align-items-center mt-2" v-if="sbFiltered.length">
<small class="text-muted">
Showing {{ (sectionBPageIndex-1)*itemsPerPage + 1 }} {{ Math.min(sectionBPageIndex*itemsPerPage, sbFiltered.length) }} of {{ sbFiltered.length }}
</small>
<div class="btn-group">
<button class="btn btn-outline-secondary btn-sm" :disabled="sectionBPageIndex===1" @@click="sectionBPageIndex--">Prev</button>
<button class="btn btn-outline-secondary btn-sm" :disabled="sectionBPageIndex*itemsPerPage>=sbFiltered.length" @@click="sectionBPageIndex++">Next</button>
</div>
</div>
</div>
</template>
</div>
<script>
const app = Vue.createApp({
data() {
const now = new Date();
return {
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
years: Array.from({ length: 10 }, (_, i) => now.getFullYear() - 5 + i),
selectedMonth: now.getMonth() + 1,
selectedYear: now.getFullYear(),
// Table 1 (Approvals)
itStatusList: [],
activeTab: 'pending',
currentPage: 1,
isApprover: false,
approverChecked: false,
// Table 2 (Section B)
sectionBList: [],
activeSbTab: 'draft',
sectionBPageIndex: 1,
isItMember: false,
sbChecked: false,
// Shared
itemsPerPage: 10,
busy: false,
error: null
};
},
computed: {
/* ======= Table 1: Approvals ======= */
filteredData() {
if (this.activeTab === 'pending') {
// show only items the current approver can act on now
return this.itStatusList.filter(r => r.canApprove === true);
}
// completed: only those where this approver already decided
return this.itStatusList.filter(r => ['Approved', 'Rejected'].includes(r.currentUserStatus));
},
paginatedData() {
const start = (this.currentPage - 1) * this.itemsPerPage;
return this.filteredData.slice(start, start + this.itemsPerPage);
},
pendingActionsCount() { return this.itStatusList.filter(r => r.canApprove).length; },
completedActionsCount() { return this.itStatusList.filter(r => ['Approved', 'Rejected'].includes(r.currentUserStatus)).length; },
/* ======= Table 2: Section B ======= */
sbFiltered() {
const map = { draft: 'DRAFT', pending: 'PENDING', awaiting: 'AWAITING', complete: 'COMPLETE'};
const want = map[this.activeSbTab];
return this.sectionBList.filter(x => x.stage === want);
},
sbPaginated() {
const start = (this.sectionBPageIndex - 1) * this.itemsPerPage;
return this.sbFiltered.slice(start, start + this.itemsPerPage);
},
sbCountDraft() { return this.sectionBList.filter(x => x.stage === 'DRAFT').length; },
sbCountPending() { return this.sectionBList.filter(x => x.stage === 'PENDING').length; },
sbCountAwaiting() { return this.sectionBList.filter(x => x.stage === 'AWAITING').length; },
sbCountComplete() { return this.sectionBList.filter(x => x.stage === 'COMPLETE').length; },
},
methods: {
/* ======= Shared helpers ======= */
formatDate(str) { if (!str) return ''; const d = new Date(str); return isNaN(d) ? str : d.toLocaleDateString(); },
getStatusBadgeClass(status) {
switch ((status || '').toLowerCase()) {
case 'approved': return 'badge bg-success';
case 'rejected': return 'badge bg-danger';
case 'pending': return 'badge bg-warning text-dark';
default: return 'badge bg-secondary';
}
},
stageBadge(stage) {
switch (stage) {
case 'COMPLETE': return { text: 'Complete', cls: 'bg-success' };
case 'PENDING': return { text: 'Pending', cls: 'bg-secondary' };
case 'DRAFT': return { text: 'Draft', cls: 'bg-info text-dark' };
case 'AWAITING': return { text: 'Awaiting Acceptances', cls: 'bg-warning text-dark' };
case 'NOT_ELIGIBLE': return { text: 'Not Eligible', cls: 'bg-dark' };
default: return { text: stage, cls: 'bg-secondary' };
}
},
/* ======= Filters / Tabs ======= */
onPeriodChange() {
// Reload both; each loader sets its own access flags
this.loadApprovals();
this.loadSectionB();
},
switchTab(tab) {
this.activeTab = tab;
this.currentPage = 1;
},
switchSbTab(tab) {
this.activeSbTab = tab;
this.sectionBPageIndex = 1;
},
/* ======= Data loaders ======= */
async loadApprovals() {
try {
this.error = null;
const r = await fetch(`/ItRequestAPI/pending?month=${this.selectedMonth}&year=${this.selectedYear}`);
if (!r.ok) throw new Error(`Load failed (${r.status})`);
const j = await r.json();
const roles = (j && (j.roles || j.Roles)) || [];
this.isApprover = Array.isArray(roles) && roles.length > 0;
this.approverChecked = true;
this.itStatusList = (j && (j.data || j.Data)) || [];
this.currentPage = 1;
} catch (e) {
this.error = e.message || 'Failed to load approvals.';
this.isApprover = false;
this.approverChecked = true;
this.itStatusList = [];
}
},
async loadSectionB() {
try {
this.error = null;
const r = await fetch(`/ItRequestAPI/sectionB/approvedList?month=${this.selectedMonth}&year=${this.selectedYear}`);
if (!r.ok) throw new Error(`Section B list failed (${r.status})`);
const j = await r.json();
this.isItMember = !!(j && j.isItMember);
this.sbChecked = true;
this.sectionBList = (j && (j.data || j.Data)) || [];
this.sectionBPageIndex = 1;
} catch (e) {
this.error = e.message || 'Failed to load Section B list.';
this.isItMember = false;
this.sbChecked = true;
this.sectionBList = [];
}
},
/* ======= Actions ======= */
async updateStatus(statusId, decision) {
try {
if (this.busy) return;
this.busy = true; this.error = null;
let comment = null;
if (decision === 'Rejected') { const input = prompt('Optional rejection comment:'); comment = input?.trim() || null; }
const res = await fetch(`/ItRequestAPI/approveReject`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ statusId, decision, comment })
});
if (!res.ok) { const j = await res.json().catch(() => ({})); throw new Error(j.message || `Failed (${res.status})`); }
await this.loadApprovals();
await this.loadSectionB();
} catch (e) { this.error = e.message || 'Something went wrong.'; }
finally { this.busy = false; }
},
async acceptIt(statusId) {
try {
if (this.busy) return;
this.busy = true; this.error = null;
const res = await fetch(`/ItRequestAPI/sectionB/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ statusId, by: 'IT' })
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.message || `IT accept failed (${res.status})`);
}
await this.loadSectionB();
} catch (e) { this.error = e.message || 'Failed to accept as IT.'; }
finally { this.busy = false; }
},
async acceptRequestor(statusId) {
try {
if (this.busy) return;
this.busy = true; this.error = null;
const res = await fetch(`/ItRequestAPI/sectionB/accept`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ statusId, by: 'REQUESTOR' })
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.message || `Accept failed (${res.status})`);
}
await this.loadSectionB();
} catch (e) { this.error = e.message || 'Failed to accept as Requestor.'; }
finally { this.busy = false; }
},
viewRequest(statusId) { window.location.href = `/IT/ApprovalDashboard/RequestReview?statusId=${statusId}`; },
openSectionB(statusId) {
const here = window.location.pathname + window.location.search + window.location.hash;
const returnUrl = encodeURIComponent(here);
window.location.href = `/IT/ApprovalDashboard/SectionB?statusId=${statusId}&returnUrl=${returnUrl}`;
},
openSectionBEdit(statusId) {
const here = window.location.pathname + window.location.search + window.location.hash;
const returnUrl = encodeURIComponent(here);
window.location.href = `/IT/ApprovalDashboard/SectionBEdit?statusId=${statusId}&returnUrl=${returnUrl}`;
},
downloadPdf(statusId) { window.open(`/ItRequestAPI/sectionB/pdf?statusId=${statusId}`, '_blank'); }
},
mounted() {
// We need to call both to determine access flags.
this.loadApprovals();
this.loadSectionB();
}
});
app.mount('#app');
</script>

View File

@ -1,825 +0,0 @@
@{
ViewData["Title"] = "New IT Request";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<style>
:root {
--card-r: 16px;
--soft-b: #eef2f6;
--soft-s: 0 8px 24px rgba(0,0,0,.08);
--muted: #6b7280;
--ok: #16a34a;
--warn: #f59e0b;
--err: #dc2626;
}
#itFormApp {
max-width: 1100px;
margin: auto;
font-size: 14px;
}
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin: 16px 0 10px;
}
.page-title {
display: flex;
align-items: center;
gap: .6rem;
margin: 0;
font-weight: 800;
letter-spacing: .2px;
}
.subtle {
color: var(--muted);
font-weight: 500;
}
.ui-card {
background: #fff;
border: 1px solid var(--soft-b);
border-radius: var(--card-r);
box-shadow: var(--soft-s);
margin-bottom: 16px;
overflow: hidden;
}
.ui-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--soft-b);
background: linear-gradient(180deg,#fbfdff,#f7fafc);
}
.ui-head h6 {
margin: 0;
font-weight: 800;
color: #0b5ed7;
}
.ui-body {
padding: 16px 18px;
}
.note {
color: var(--muted);
font-size: 12px;
}
.chip {
display: inline-flex;
align-items: center;
gap: .45rem;
padding: .25rem .6rem;
border-radius: 999px;
font-weight: 700;
font-size: 12px;
border: 1px solid rgba(0,0,0,.06);
background: #eef2f7;
color: #334155;
}
.chip i {
font-size: 14px;
}
.chip-ok {
background: #e7f7ed;
color: #166534;
border-color: #c7ecd3;
}
.chip-warn {
background: #fff7e6;
color: #92400e;
border-color: #fdebd1;
}
.form-label .req {
color: var(--err);
margin-left: .2rem;
}
.invalid-hint {
color: var(--err);
font-size: 12px;
margin-top: 4px;
}
.req-grid {
display: grid;
grid-template-columns: repeat(2,1fr);
gap: 12px 18px;
}
@@media (max-width:768px) {
.req-grid {
grid-template-columns: 1fr;
}
}
.mini-table {
border: 1px solid var(--soft-b);
border-radius: 12px;
overflow: hidden;
}
.mini-head {
background: #f3f6fb;
padding: 10px 12px;
font-weight: 700;
color: #334155;
font-size: 12px;
text-transform: uppercase;
letter-spacing: .4px;
}
.mini-row {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 12px;
border-top: 1px solid #f1f5f9;
}
.mini-row:hover {
background: #fafcff;
}
.btn-soft {
border-radius: 10px;
padding: .5rem .8rem;
font-weight: 700;
letter-spacing: .2px;
border: 1px solid transparent;
box-shadow: 0 2px 8px rgba(0,0,0,.06);
}
.btn-add {
background: #0b5ed7;
color: #fff;
}
.btn-add:hover {
background: #0a53be;
}
.btn-del {
background: #fff;
color: #dc2626;
border: 1px solid #f1d2d2;
}
.btn-del:hover {
background: #fff5f5;
}
.stacked-checks .form-check {
margin-bottom: .4rem;
}
.submit-bar {
position: sticky;
bottom: 12px;
z-index: 5;
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px;
border-radius: 12px;
background: rgba(255,255,255,.85);
backdrop-filter: blur(6px);
border: 1px solid var(--soft-b);
box-shadow: var(--soft-s);
margin: 6px 0 30px;
}
.btn-go {
background: #22c55e;
color: #fff;
border-radius: 10px;
padding: .6rem .95rem;
font-weight: 800;
}
.btn-go:hover {
background: #16a34a;
}
.btn-send {
background: #0b5ed7;
color: #fff;
border-radius: 10px;
padding: .6rem .95rem;
font-weight: 800;
}
.btn-send:hover {
background: #0a53be;
}
.btn-reset {
background: #fff;
color: #334155;
border: 1px solid var(--soft-b);
border-radius: 10px;
padding: .6rem .95rem;
font-weight: 700;
}
.btn-reset:hover {
background: #f8fafc;
}
.muted {
color: var(--muted);
}
.perm-flags {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.divider {
height: 1px;
background: #eef2f6;
margin: 8px 0;
}
</style>
<div id="itFormApp">
<div class="page-head">
<h3 class="page-title"></h3>
<div class="subtle">Stages: HOD → Group IT HOD → Finance HOD → Management</div>
</div>
<!-- Requester -->
<div class="ui-card">
<div class="ui-head">
<h6><i class="bi bi-person-badge"></i> Requester Details</h6>
<span class="note">These fields are snapshotted at submission</span>
</div>
<div class="ui-body">
<div class="req-grid">
<div>
<label class="form-label">Staff Name</label>
<input type="text" class="form-control" v-model.trim="model.staffName" readonly>
</div>
<div>
<label class="form-label">Designation</label>
<input type="text" class="form-control" v-model.trim="model.designation" readonly>
</div>
<div>
<label class="form-label">Company</label>
<input type="text" class="form-control" v-model.trim="model.companyName" readonly>
</div>
<div>
<label class="form-label">Div/Dept</label>
<input type="text" class="form-control" v-model.trim="model.departmentName" readonly>
</div>
<div>
<label class="form-label">Location</label>
<input type="text" class="form-control" v-model.trim="model.location" readonly>
</div>
<div>
<label class="form-label">Phone Ext</label>
<input type="text" class="form-control" v-model.trim="model.phoneExt" readonly>
</div>
<div>
<label class="form-label">Employment Status</label>
<select class="form-select" v-model="model.employmentStatus" disabled>
<option value="">--</option>
<option>Permanent</option>
<option>Contract</option>
<option>Temp</option>
<option>New Staff</option>
</select>
</div>
<div v-if="model.employmentStatus==='Contract' || model.employmentStatus==='Temp'">
<label class="form-label">Contract End Date</label>
<input type="date" class="form-control" v-model="model.contractEndDate" readonly>
</div>
<div>
<label class="form-label">Required Date <span class="req">*</span></label>
<input type="date" class="form-control" v-model="model.requiredDate" :min="minReqISO">
<div class="invalid-hint" v-if="validation.requiredDate">{{ validation.requiredDate }}</div>
</div>
</div>
</div>
</div>
<!-- Hardware -->
<div class="ui-card">
<div class="ui-head">
<h6><i class="bi bi-cpu"></i> Hardware Requirements</h6>
<div class="d-flex align-items-center gap-2">
<span class="chip" v-if="hardwareCount===0"><i class="bi bi-inboxes"></i>None selected</span>
<span class="chip chip-ok" v-else><i class="bi bi-check2"></i>{{ hardwareCount }} selected</span>
</div>
</div>
<div class="ui-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Purpose <span class="req" v-if="hardwareCount>0">*</span></label>
<select class="form-select" v-model="hardwarePurpose">
<option value="">-- Select --</option>
<option value="NewRecruitment">New Staff Recruitment</option>
<option value="Replacement">Replacement</option>
<option value="Additional">Additional</option>
</select>
<div class="invalid-hint" v-if="validation.hardwarePurpose">{{ validation.hardwarePurpose }}</div>
<label class="form-label mt-3">Justification (for hardware change)</label>
<textarea class="form-control" rows="3" v-model="hardwareJustification" placeholder="-"></textarea>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center justify-content-between">
<label class="form-label">Select below</label>
<div class="small text-muted">All-inclusive toggles will auto-select sensible accessories</div>
</div>
<div class="stacked-checks">
<div class="form-check" v-for="opt in hardwareCategories" :key="opt.key">
<input class="form-check-input" type="checkbox" :id="'cat_'+opt.key"
v-model="opt.include" @@change="onHardwareToggle(opt.key)">
<label class="form-check-label" :for="'cat_'+opt.key">{{ opt.label }}</label>
</div>
</div>
<div class="mt-2">
<label class="form-label">Other (Specify)</label>
<input class="form-control form-control-sm" v-model.trim="hardwareOther" placeholder="-">
</div>
</div>
</div>
</div>
</div>
<!-- Email -->
<div class="ui-card">
<div class="ui-head">
<h6><i class="bi bi-envelope-paper"></i> Email</h6>
<div class="d-flex align-items-center gap-2">
<span class="note">Enter proposed address(es) without <code>@@domain</code></span>
<button class="btn-soft btn-add" @@click="addEmail"><i class="bi bi-plus"></i> Add</button>
</div>
</div>
<div class="ui-body">
<div class="mini-table">
<div class="mini-head">Proposed Address (without @@domain)</div>
<div v-for="(row, i) in emailRows" :key="'em-'+i" class="mini-row">
<div class="flex-grow-1">
<input class="form-control form-control-sm" v-model.trim="row.proposedAddress" placeholder="e.g. j.doe">
</div>
<button class="btn btn-del btn-sm" @@click="removeEmail(i)"><i class="bi bi-x"></i></button>
</div>
<div v-if="emailRows.length===0" class="mini-row">
<div class="text-muted">No email rows</div>
</div>
</div>
</div>
</div>
<!-- OS -->
<div class="ui-card">
<div class="ui-head">
<h6><i class="bi bi-windows"></i> Operating System Requirements</h6>
<button class="btn-soft btn-add" @@click="addOs"><i class="bi bi-plus"></i> Add</button>
</div>
<div class="ui-body">
<div class="mini-table">
<div class="mini-head">Requirement</div>
<div v-for="(row, i) in osReqs" :key="'os-'+i" class="mini-row">
<textarea class="form-control" rows="2" v-model="row.requirementText" placeholder="e.g. Windows 11 Pro required due to ..."></textarea>
<button class="btn btn-del btn-sm" @@click="removeOs(i)"><i class="bi bi-x"></i></button>
</div>
<div v-if="osReqs.length===0" class="mini-row">
<div class="text-muted">No OS requirements</div>
</div>
</div>
</div>
</div>
<!-- Software -->
<div class="ui-card">
<div class="ui-head">
<h6><i class="bi bi-boxes"></i> Software</h6>
<span class="note">Tick to include; use Others to specify anything not listed</span>
</div>
<div class="ui-body">
<div class="row g-3">
<div class="col-md-4">
<h6 class="mb-2">General Software</h6>
<div class="form-check" v-for="opt in softwareGeneralOpts" :key="'gen-'+opt">
<input class="form-check-input" type="checkbox" :id="'gen_'+opt" v-model="softwareGeneral[opt]">
<label class="form-check-label" :for="'gen_'+opt">{{ opt }}</label>
</div>
<div class="mt-2">
<label class="form-label">Others (Specify)</label>
<input class="form-control form-control-sm" v-model.trim="softwareGeneralOther" placeholder="-">
</div>
</div>
<div class="col-md-4">
<h6 class="mb-2">Utility Software</h6>
<div class="form-check" v-for="opt in softwareUtilityOpts" :key="'utl-'+opt">
<input class="form-check-input" type="checkbox" :id="'utl_'+opt" v-model="softwareUtility[opt]">
<label class="form-check-label" :for="'utl_'+opt">{{ opt }}</label>
</div>
<div class="mt-2">
<label class="form-label">Others (Specify)</label>
<input class="form-control form-control-sm" v-model.trim="softwareUtilityOther" placeholder="-">
</div>
</div>
<div class="col-md-4">
<h6 class="mb-2">Custom Software</h6>
<label class="form-label">Others (Specify)</label>
<input class="form-control form-control-sm" v-model.trim="softwareCustomOther" placeholder="-">
</div>
</div>
</div>
</div>
<!-- Shared Permissions (CAP 6) -->
<div class="ui-card">
<div class="ui-head">
<h6><i class="bi bi-share"></i> Shared Permissions</h6>
<div class="d-flex align-items-center gap-2">
<span class="note">Max 6 entries</span>
<button class="btn-soft btn-add" @@click="addPerm" :disabled="sharedPerms.length>=6"><i class="bi bi-plus"></i> Add</button>
</div>
</div>
<div class="ui-body">
<div class="mini-table">
<div class="mini-head">Share Name &amp; 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>
}

View File

@ -1,698 +0,0 @@
@{
ViewData["Title"] = "Edit IT Request";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<style>
:root {
--card-r: 16px;
--soft-b: #eef2f6;
--soft-s: 0 8px 24px rgba(0,0,0,.08);
--muted: #6b7280;
}
#editApp {
max-width: 1100px;
margin: auto;
font-size: 14px;
}
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin: 16px 0 10px;
}
.subtle {
color: var(--muted);
font-weight: 500;
}
.ui-card {
background: #fff;
border: 1px solid var(--soft-b);
border-radius: var(--card-r);
box-shadow: var(--soft-s);
margin-bottom: 16px;
overflow: hidden;
}
.ui-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--soft-b);
background: linear-gradient(180deg,#fbfdff,#f7fafc);
}
.ui-head h6 {
margin: 0;
font-weight: 800;
color: #0b5ed7;
}
.ui-body {
padding: 16px 18px;
}
.note {
color: var(--muted);
font-size: 12px;
}
.mini-table {
border: 1px solid var(--soft-b);
border-radius: 12px;
overflow: hidden;
}
.mini-head {
background: #f3f6fb;
padding: 10px 12px;
font-weight: 700;
color: #334155;
font-size: 12px;
text-transform: uppercase;
letter-spacing: .4px;
}
.mini-row {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 12px;
border-top: 1px solid #f1f5f9;
}
.mini-row:hover {
background: #fafcff;
}
.btn-soft {
border-radius: 10px;
padding: .5rem .8rem;
font-weight: 700;
letter-spacing: .2px;
border: 1px solid transparent;
box-shadow: 0 2px 8px rgba(0,0,0,.06);
}
.btn-add {
background: #0b5ed7;
color: #fff;
}
.btn-add:hover {
background: #0a53be;
}
.btn-del {
background: #fff;
color: #dc2626;
border: 1px solid #f1d2d2;
}
.btn-del:hover {
background: #fff5f5;
}
.stacked-checks .form-check {
margin-bottom: .4rem;
}
.submit-bar {
position: sticky;
bottom: 12px;
z-index: 5;
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px;
border-radius: 12px;
background: rgba(255,255,255,.85);
backdrop-filter: blur(6px);
border: 1px solid var(--soft-b);
box-shadow: var(--soft-s);
margin: 6px 0 30px;
}
.btn-go {
background: #22c55e;
color: #fff;
border-radius: 10px;
padding: .6rem .95rem;
font-weight: 800;
}
.btn-go:hover {
background: #16a34a;
}
.btn-reset {
background: #fff;
color: #334155;
border: 1px solid var(--soft-b);
border-radius: 10px;
padding: .6rem .95rem;
font-weight: 700;
}
.btn-reset:hover {
background: #f8fafc;
}
.invalid-hint {
color: #dc2626;
font-size: 12px;
margin-top: 4px;
}
.countdown-box {
display: inline-flex;
align-items: center;
font-weight: 700;
font-size: 13px;
border-radius: 8px;
padding: 4px 10px;
border: 1px solid #e2e8f0;
box-shadow: 0 2px 6px rgba(0,0,0,.05);
min-width: 80px;
justify-content: center;
transition: background .3s ease, color .3s ease;
}
.countdown-active {
background: #ecfdf5;
color: #166534;
border-color: #bbf7d0;
}
.countdown-expired {
background: #fef2f2;
color: #991b1b;
border-color: #fecaca;
}
.perm-flags {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
</style>
<div id="editApp">
<div class="page-head">
<div class="subtle d-flex align-items-center gap-2">
<span class="badge" :class="isEditable ? 'bg-success' : 'bg-secondary'">{{ isEditable ? 'Editable' : 'Locked' }}</span>
<div class="countdown-box" :class="isEditable ? 'countdown-active' : 'countdown-expired'">
<i class="bi bi-clock-history me-1"></i>
<span id="countdown">—</span>
</div>
</div>
</div>
<!-- Requester -->
<div class="ui-card">
<div class="ui-head">
<h6><i class="bi bi-person-badge"></i> Requester Details</h6>
<span class="note">These fields are snapshotted at submission</span>
</div>
<div class="ui-body">
<div class="req-grid">
<div>
<label class="form-label">Staff Name</label>
<input type="text" class="form-control" v-model.trim="model.staffName" readonly>
</div>
<div>
<label class="form-label">Designation</label>
<input type="text" class="form-control" v-model.trim="model.designation" :disabled="!isEditable">
</div>
<div>
<label class="form-label">Company</label>
<input type="text" class="form-control" v-model.trim="model.companyName" readonly>
</div>
<div>
<label class="form-label">Div/Dept</label>
<input type="text" class="form-control" v-model.trim="model.departmentName" readonly>
</div>
<div>
<label class="form-label">Location</label>
<input type="text" class="form-control" v-model.trim="model.location" :disabled="!isEditable">
</div>
<div>
<label class="form-label">Phone Ext</label>
<input type="text" class="form-control" v-model.trim="model.phoneExt" :disabled="!isEditable">
</div>
<div>
<label class="form-label">Employment Status</label>
<select class="form-select" v-model="model.employmentStatus" :disabled="!isEditable">
<option value="">--</option>
<option>Permanent</option>
<option>Contract</option>
<option>Temp</option>
<option>New Staff</option>
</select>
</div>
<div v-if="model.employmentStatus==='Contract' || model.employmentStatus==='Temp'">
<label class="form-label">Contract End Date</label>
<input type="date" class="form-control" v-model="model.contractEndDate" :disabled="!isEditable">
</div>
<div>
<label class="form-label">Required Date <span class="text-danger">*</span></label>
<input type="date" class="form-control" v-model="model.requiredDate" :min="minReqISO" :disabled="!isEditable">
<div class="invalid-hint" v-if="validation.requiredDate">{{ validation.requiredDate }}</div>
</div>
</div>
</div>
</div>
<!-- Hardware -->
<div class="ui-card">
<div class="ui-head">
<h6><i class="bi bi-cpu"></i> Hardware Requirements</h6>
</div>
<div class="ui-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Purpose <span class="text-danger" v-if="hardwareCount>0">*</span></label>
<select class="form-select" v-model="hardwarePurpose" :disabled="!isEditable">
<option value="">-- Select --</option>
<option value="NewRecruitment">New Staff Recruitment</option>
<option value="Replacement">Replacement</option>
<option value="Additional">Additional</option>
</select>
<div class="invalid-hint" v-if="validation.hardwarePurpose">{{ validation.hardwarePurpose }}</div>
<label class="form-label mt-3">Justification (for hardware change)</label>
<textarea class="form-control" rows="3" v-model="hardwareJustification" :disabled="!isEditable" placeholder="-"></textarea>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center justify-content-between">
<label class="form-label">Select below</label>
<div class="small text-muted">All-inclusive toggles will auto-select sensible accessories</div>
</div>
<div class="stacked-checks">
<div class="form-check" v-for="opt in hardwareCategories" :key="opt.key">
<input class="form-check-input" type="checkbox" :id="'cat_'+opt.key"
v-model="opt.include" :disabled="!isEditable" @@change="onHardwareToggle(opt.key)">
<label class="form-check-label" :for="'cat_'+opt.key">{{ opt.label }}</label>
</div>
</div>
<div class="mt-2">
<label class="form-label">Other (Specify)</label>
<input class="form-control form-control-sm" v-model.trim="hardwareOther" :disabled="!isEditable" placeholder="-">
</div>
</div>
</div>
</div>
</div>
<!-- Email -->
<div class="ui-card">
<div class="ui-head">
<h6><i class="bi bi-envelope-paper"></i> Email</h6>
<div class="d-flex align-items-center gap-2">
<span class="note">Enter proposed address(es) without <code>@@domain</code></span>
<button class="btn-soft btn-add" @@click="addEmail" :disabled="!isEditable"><i class="bi bi-plus"></i> Add</button>
</div>
</div>
<div class="ui-body">
<div class="mini-table">
<div class="mini-head">Proposed Address (without @@domain)</div>
<div v-for="(row, i) in emailRows" :key="'em-'+i" class="mini-row">
<div class="flex-grow-1">
<input class="form-control form-control-sm" v-model.trim="row.proposedAddress" :disabled="!isEditable" placeholder="e.g. j.doe">
</div>
<button class="btn btn-del btn-sm" @@click="removeEmail(i)" :disabled="!isEditable"><i class="bi bi-x"></i></button>
</div>
<div v-if="emailRows.length===0" class="mini-row">
<div class="text-muted">No email rows</div>
</div>
</div>
</div>
</div>
<!-- OS -->
<div class="ui-card">
<div class="ui-head">
<h6><i class="bi bi-windows"></i> Operating System Requirements</h6>
<button class="btn-soft btn-add" @@click="addOs" :disabled="!isEditable"><i class="bi bi-plus"></i> Add</button>
</div>
<div class="ui-body">
<div class="mini-table">
<div class="mini-head">Requirement</div>
<div v-for="(row, i) in osReqs" :key="'os-'+i" class="mini-row">
<textarea class="form-control" rows="2" v-model="row.requirementText" :disabled="!isEditable" placeholder="e.g. Windows 11 Pro required due to ..."></textarea>
<button class="btn btn-del btn-sm" @@click="removeOs(i)" :disabled="!isEditable"><i class="bi bi-x"></i></button>
</div>
<div v-if="osReqs.length===0" class="mini-row">
<div class="text-muted">No OS requirements</div>
</div>
</div>
</div>
</div>
<!-- Software -->
<div class="ui-card">
<div class="ui-head">
<h6><i class="bi bi-boxes"></i> Software</h6>
<span class="note">Tick to include; use Others to specify anything not listed</span>
</div>
<div class="ui-body">
<div class="row g-3">
<div class="col-md-4">
<h6 class="mb-2">General Software</h6>
<div class="form-check" v-for="opt in softwareGeneralOpts" :key="'gen-'+opt">
<input class="form-check-input" type="checkbox" :id="'gen_'+opt" v-model="softwareGeneral[opt]" :disabled="!isEditable">
<label class="form-check-label" :for="'gen_'+opt">{{ opt }}</label>
</div>
<div class="mt-2">
<label class="form-label">Others (Specify)</label>
<input class="form-control form-control-sm" v-model.trim="softwareGeneralOther" :disabled="!isEditable" placeholder="-">
</div>
</div>
<div class="col-md-4">
<h6 class="mb-2">Utility Software</h6>
<div class="form-check" v-for="opt in softwareUtilityOpts" :key="'utl-'+opt">
<input class="form-check-input" type="checkbox" :id="'utl_'+opt" v-model="softwareUtility[opt]" :disabled="!isEditable">
<label class="form-check-label" :for="'utl_'+opt">{{ opt }}</label>
</div>
<div class="mt-2">
<label class="form-label">Others (Specify)</label>
<input class="form-control form-control-sm" v-model.trim="softwareUtilityOther" :disabled="!isEditable" placeholder="-">
</div>
</div>
<div class="col-md-4">
<h6 class="mb-2">Custom Software</h6>
<label class="form-label">Others (Specify)</label>
<input class="form-control form-control-sm" v-model.trim="softwareCustomOther" :disabled="!isEditable" placeholder="-">
</div>
</div>
</div>
</div>
<!-- Shared Permissions (CAP 6) -->
<div class="ui-card">
<div class="ui-head">
<h6><i class="bi bi-share"></i> Shared Permissions</h6>
<div class="d-flex align-items-center gap-2">
<span class="note">Max 6 entries</span>
<button class="btn-soft btn-add" @@click="addPerm" :disabled="!isEditable || sharedPerms.length>=6"><i class="bi bi-plus"></i> Add</button>
</div>
</div>
<div class="ui-body">
<div class="mini-table">
<div class="mini-head">Share Name &amp; 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 wont 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>
}

View File

@ -1,557 +0,0 @@
@{
ViewData["Title"] = "My IT Requests";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<style>
.container-outer {
max-width: 1300px;
margin: auto;
font-size: 13px;
}
.table-container {
background: #fff;
border-radius: 14px;
box-shadow: 0 6px 18px rgba(0,0,0,.06);
padding: 16px;
margin-bottom: 16px;
}
.filters .form-label {
font-size: 12px;
margin-bottom: 4px;
}
.nav-tabs .badge {
margin-left: 6px;
}
.empty {
text-align: center;
padding: 18px;
color: #6b7280;
}
.empty i {
display: block;
font-size: 22px;
margin-bottom: 6px;
opacity: .7;
}
.skeleton {
position: relative;
background: #f1f5f9;
overflow: hidden;
border-radius: 6px;
height: 28px;
}
.skeleton::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.6), transparent);
animation: shimmer 1.2s infinite;
transform: translateX(-100%);
}
@@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.pillbar button {
margin-right: 6px;
}
.section-title {
font-weight: 700;
font-size: 16px;
margin: 0 0 10px;
display: flex;
align-items: center;
gap: 8px;
}
.section-title .hint {
font-weight: 500;
color: #6b7280;
font-size: 12px;
}
</style>
<div id="myReqApp" class="container-outer">
<h3 class="mb-4 fw-bold"></h3>
<!-- Filters -->
<div class="row mb-3 align-items-end filters">
<div class="col-md-auto me-3">
<label class="form-label">Month</label>
<select class="form-control form-control-sm" v-model.number="selectedMonth" @@change="fetchData">
<option v-for="(m, i) in months" :key="i" :value="i + 1">{{ m }}</option>
</select>
</div>
<div class="col-md-auto">
<label class="form-label">Year</label>
<select class="form-control form-control-sm" v-model.number="selectedYear" @@change="fetchData">
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
</select>
</div>
</div>
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
<div v-if="busy" class="alert alert-secondary py-2">Loading…</div>
<!-- ===================== TABLE 1: MAIN REQUESTS (Draft/Pending/Approved/Rejected/Cancelled) ===================== -->
<div class="table-container table-responsive">
<ul class="nav nav-tabs mb-3">
<li class="nav-item"><a class="nav-link" :class="{ active: activeTab === 'Draft' }" href="#" @@click.prevent="switchTab('Draft')">Draft <span class="badge bg-info">{{ counts.draft }}</span></a></li>
<li class="nav-item"><a class="nav-link" :class="{ active: activeTab === 'Pending' }" href="#" @@click.prevent="switchTab('Pending')">Pending <span class="badge bg-warning text-dark">{{ counts.pending }}</span></a></li>
<li class="nav-item"><a class="nav-link" :class="{ active: activeTab === 'Approved' }" href="#" @@click.prevent="switchTab('Approved')">Approved <span class="badge bg-success">{{ counts.approved }}</span></a></li>
<li class="nav-item"><a class="nav-link" :class="{ active: activeTab === 'Rejected' }" href="#" @@click.prevent="switchTab('Rejected')">Rejected <span class="badge bg-danger">{{ counts.rejected }}</span></a></li>
<li class="nav-item"><a class="nav-link" :class="{ active: activeTab === 'Cancelled' }" href="#" @@click.prevent="switchTab('Cancelled')">Cancelled <span class="badge bg-secondary">{{ counts.cancelled }}</span></a></li>
</ul>
<table class="table table-bordered table-sm table-striped align-middle text-center">
<thead class="table-light">
<tr>
<th>Department</th>
<th>Company</th>
<th>Required Date</th>
<th>Date Submitted</th>
<th style="width:260px;">Action</th>
</tr>
</thead>
<tbody v-if="busy">
<tr v-for="n in 5" :key="'sk-main-'+n">
<td colspan="5"><div class="skeleton"></div></td>
</tr>
</tbody>
<tbody v-else>
<tr v-for="r in paginatedData" :key="'row-'+activeTab+'-'+r.statusId">
<td>{{ r.departmentName }}</td>
<td>{{ r.companyName }}</td>
<td>{{ fmtDate(r.requiredDate) }}</td>
<td>{{ fmtDateTime(r.submitDate) }}</td>
<td>
<div class="d-flex justify-content-center align-items-center">
<!-- View: Draft -> Edit page, others -> RequestReview -->
<button class="btn btn-primary btn-sm me-1" @@click="view(r)">View</button>
<!-- Cancel: Draft only (server enforces final business rules) -->
<button v-if="activeTab==='Draft'"
class="btn btn-outline-danger btn-sm"
:disabled="cancellingId === r.itRequestId"
@@click="cancel(r.itRequestId)">
<span v-if="cancellingId === r.itRequestId" class="spinner-border spinner-border-sm me-1"></span>
Cancel
</button>
</div>
</td>
</tr>
<tr v-if="!paginatedData.length" key="empty-main">
<td colspan="5" class="empty"><i class="bi bi-inboxes"></i> No {{ activeTab.toLowerCase() }} requests</td>
</tr>
</tbody>
</table>
<!-- Pagination (Main) -->
<div class="d-flex justify-content-between align-items-center mt-2" v-if="filteredData.length">
<small class="text-muted">
Showing {{ (currentPage - 1) * itemsPerPage + 1 }} {{ Math.min(currentPage * itemsPerPage, filteredData.length) }}
of {{ filteredData.length }}
</small>
<div class="d-flex align-items-center gap-2">
<div class="text-muted">Rows</div>
<select class="form-select form-select-sm" style="width:90px" v-model.number="itemsPerPage" @@change="currentPage=1">
<option :value="5">5</option>
<option :value="10">10</option>
<option :value="20">20</option>
<option :value="50">50</option>
</select>
<div class="btn-group">
<button class="btn btn-outline-secondary btn-sm" :disabled="currentPage===1" @@click="currentPage--">Prev</button>
<button class="btn btn-outline-secondary btn-sm" :disabled="currentPage * itemsPerPage >= filteredData.length" @@click="currentPage++">Next</button>
</div>
</div>
</div>
</div>
<!-- ===================== TABLE 2: SECTION B (SECOND TABLE) ===================== -->
<div class="table-container table-responsive">
<div class="section-title">
<span><i class="bi bi-clipboard-check"></i> Section B</span>
<span class="hint">Requests with overall status Approved/Completed</span>
</div>
<!-- Section B sub-filters + callout -->
<div class="d-flex align-items-center justify-content-between flex-wrap mb-2">
<div class="pillbar">
<span class="text-muted me-2">Show:</span>
<button type="button" class="btn btn-sm"
:class="sbSubTab==='need' ? 'btn-primary' : 'btn-outline-secondary'"
@@click="setSbSubTab('need')">
Your Acceptance <span class="badge bg-light text-dark ms-1">{{ sbCount.need }}</span>
</button>
<button type="button" class="btn btn-sm"
:class="sbSubTab==='waiting' ? 'btn-primary' : 'btn-outline-secondary'"
@@click="setSbSubTab('waiting')">
Waiting IT <span class="badge bg-light text-dark ms-1">{{ sbCount.waiting }}</span>
</button>
<button type="button" class="btn btn-sm"
:class="sbSubTab==='notstarted' ? 'btn-primary' : 'btn-outline-secondary'"
@@click="setSbSubTab('notstarted')">
Not Started <span class="badge bg-light text-dark ms-1">{{ sbCount.notStarted }}</span>
</button>
<button type="button" class="btn btn-sm"
:class="sbSubTab==='complete' ? 'btn-primary' : 'btn-outline-secondary'"
@@click="setSbSubTab('complete')">
Complete <span class="badge bg-light text-dark ms-1">{{ sbCount.complete }}</span>
</button>
</div>
<div v-if="sbCount.need > 0" class="alert alert-warning py-1 px-2 m-0">
You have {{ sbCount.need }} Section B {{ sbCount.need===1 ? 'item' : 'items' }} that need your acceptance.
</div>
</div>
<table class="table table-bordered table-sm table-striped align-middle text-center">
<thead class="table-light">
<tr>
<th>Department</th>
<th>Company</th>
<th>Section B Status</th>
<th style="width:360px;">Action</th>
</tr>
</thead>
<!-- Skeleton while meta loads -->
<tbody v-if="busy && !sbLoaded">
<tr v-for="n in 4" :key="'sk-sb-'+n">
<td colspan="4"><div class="skeleton"></div></td>
</tr>
</tbody>
<!-- Section B rows -->
<tbody v-else>
<tr v-for="r in sectionBPage" :key="'sb-'+r.statusId">
<td>{{ r.departmentName }}</td>
<td>{{ r.companyName }}</td>
<td>
<span class="badge"
:class="r.sb.itAccepted && r.sb.requestorAccepted ? 'bg-success'
: (r.sb.saved ? 'bg-warning text-dark' : 'bg-secondary')">
<template v-if="r.sb.itAccepted && r.sb.requestorAccepted">Approved</template>
<template v-else-if="r.sb.saved && !r.sb.requestorAccepted">Your Acceptance</template>
<template v-else-if="r.sb.saved && r.sb.requestorAccepted && !r.sb.itAccepted">Waiting IT</template>
<template v-else>Not Started</template>
</span>
</td>
<td>
<!-- ACTION RULES BY SUBTAB -->
<div class="d-flex justify-content-center align-items-center flex-wrap" style="gap:6px;">
<!-- Always show View -->
<button class="btn btn-primary btn-sm" @@click="openSectionB(r.statusId)">View</button>
<!-- Your Acceptance tab ONLY: show Accept (Requestor) -->
<button v-if="sbSubTab==='need'"
class="btn btn-outline-dark btn-sm"
:disabled="busy || !r.sb.saved || r.sb.requestorAccepted"
@@click="acceptRequestor(r.statusId)">
Accept (Requestor)
</button>
<!-- Complete tab ONLY: show PDF -->
<button v-if="sbSubTab==='complete' && r.sb.itAccepted && r.sb.requestorAccepted"
class="btn btn-outline-secondary btn-sm"
@@click="downloadPdf(r.statusId)">
PDF
</button>
<!-- Not Started / Waiting IT: no other actions (View only) -->
</div>
</td>
</tr>
<!-- Empty state -->
<tr v-if="sbLoaded && sectionBFiltered.length === 0">
<td colspan="4" class="empty"><i class="bi bi-inboxes"></i> No Section B items in this view</td>
</tr>
</tbody>
</table>
<!-- Pagination (Section B) -->
<div class="d-flex justify-content-between align-items-center mt-2" v-if="sectionBFiltered.length">
<small class="text-muted">
Showing {{ (sectionBPageIndex - 1) * itemsPerPage + 1 }} {{ Math.min(sectionBPageIndex * itemsPerPage, sectionBFiltered.length) }}
of {{ sectionBFiltered.length }}
</small>
<div class="btn-group">
<button class="btn btn-outline-secondary btn-sm" :disabled="sectionBPageIndex===1" @@click="sectionBPageIndex--">Prev</button>
<button class="btn btn-outline-secondary btn-sm" :disabled="sectionBPageIndex * itemsPerPage >= sectionBFiltered.length" @@click="sectionBPageIndex++">Next</button>
</div>
</div>
</div>
</div>
@section Scripts {
<script>
const app = Vue.createApp({
data() {
const now = new Date();
return {
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
years: Array.from({ length: 10 }, (_, i) => now.getFullYear() - 5 + i),
selectedMonth: now.getMonth() + 1,
selectedYear: now.getFullYear(),
busy: false, error: null, allRows: [],
// MAIN table
activeTab: 'Pending',
currentPage: 1, itemsPerPage: 10,
cancellingId: 0,
// SECTION B (second table)
sbMetaMap: {}, // statusId -> { saved, requestorAccepted, itAccepted, lastEditedBy }
sbLoaded: false,
sectionBPageIndex: 1,
sbSubTab: 'need', // 'need' | 'waiting' | 'notstarted' | 'complete'
// Which overall statuses are eligible for Section B
SECTIONB_ALLOW_STATUSES: ['Approved', 'Completed']
};
},
computed: {
// Badge counts for main tabs
counts() {
const c = { draft: 0, pending: 0, approved: 0, rejected: 0, cancelled: 0 };
this.allRows.forEach(r => {
const s = (r.overallStatus || 'Pending');
if (s === 'Draft') c.draft++;
else if (s === 'Pending') c.pending++;
else if (s === 'Approved') c.approved++;
else if (s === 'Rejected') c.rejected++;
else if (s === 'Cancelled') c.cancelled++;
});
return c;
},
// MAIN table filtering & pagination
filteredData() {
const tab = this.activeTab;
return this.allRows.filter(r => (r.overallStatus || 'Pending') === tab);
},
paginatedData() {
const start = (this.currentPage - 1) * this.itemsPerPage;
return this.filteredData.slice(start, start + this.itemsPerPage);
},
// Build Section B candidate rows and sort by priority
sectionBRows() {
const rows = this.allRows
.filter(r => this.SECTIONB_ALLOW_STATUSES.includes(r.overallStatus || ''))
.map(r => ({
...r,
sb: this.sbMetaMap[r.statusId] || { saved: false, requestorAccepted: false, itAccepted: false, lastEditedBy: null }
}));
const rank = (x) => {
if (x.sb.itAccepted && x.sb.requestorAccepted) return 3; // complete (lowest priority)
if (!x.sb.saved) return 1; // not started
if (x.sb.saved && x.sb.requestorAccepted && !x.sb.itAccepted) return 2; // waiting IT
if (x.sb.saved && !x.sb.requestorAccepted) return 4; // needs requestor (highest)
return 0;
};
return rows.slice().sort((a, b) => {
const rb = rank(b) - rank(a);
if (rb !== 0) return rb;
return new Date(b.submitDate) - new Date(a.submitDate);
});
},
// Section B counts for pills
sbCount() {
const c = { need: 0, waiting: 0, notStarted: 0, complete: 0 };
this.sectionBRows.forEach(r => {
const st = this.sbStageOf(r.sb);
if (st === 'need') c.need++;
else if (st === 'waiting') c.waiting++;
else if (st === 'notstarted') c.notStarted++;
else if (st === 'complete') c.complete++;
});
return c;
},
// Apply Section B sub-filter & pagination
sectionBFiltered() {
const want = this.sbSubTab; // 'need'|'waiting'|'notstarted'|'complete'
return this.sectionBRows.filter(r => this.sbStageOf(r.sb) === want);
},
sectionBPage() {
const start = (this.sectionBPageIndex - 1) * this.itemsPerPage;
return this.sectionBFiltered.slice(start, start + this.itemsPerPage);
}
},
methods: {
// MAIN table
switchTab(tab) {
this.activeTab = tab;
this.currentPage = 1;
},
async fetchData() {
try {
this.busy = true; this.error = null;
const y = this.selectedYear, m = this.selectedMonth;
// Inclusive month range (UTC)
const from = new Date(Date.UTC(y, m - 1, 1, 0, 0, 0));
const to = new Date(Date.UTC(y, m, 0, 23, 59, 59, 999));
const params = new URLSearchParams();
params.set('from', from.toISOString());
params.set('to', to.toISOString());
params.set('page', '1');
params.set('pageSize', '500');
const res = await fetch(`/ItRequestAPI/myRequests?${params.toString()}`);
if (!res.ok) throw new Error(`Load failed (${res.status})`);
const data = await res.json();
this.allRows = (data.data || []).map(x => ({
itRequestId: x.itRequestId,
statusId: x.statusId,
departmentName: x.departmentName,
companyName: x.companyName,
requiredDate: x.requiredDate,
submitDate: x.submitDate,
overallStatus: x.overallStatus || 'Pending'
}));
// Reset Section B cache and load fresh meta
this.sbMetaMap = {};
this.sbLoaded = false;
await this.loadSectionBMeta();
} catch (e) {
this.error = e.message || 'Failed to load.';
} finally {
this.busy = false;
}
},
fmtDate(d) { if (!d) return ''; const dt = new Date(d); return isNaN(dt) ? d : dt.toLocaleDateString(); },
fmtDateTime(d) { if (!d) return ''; const dt = new Date(d); return isNaN(dt) ? d : dt.toLocaleString(); },
view(row) {
if (this.activeTab === 'Draft') {
window.location.href = `/IT/ApprovalDashboard/Edit?statusId=${row.statusId}`;
} else {
window.location.href = `/IT/ApprovalDashboard/RequestReview?statusId=${row.statusId}`;
}
},
async cancel(requestId) {
if (!confirm('Cancel this request? This cannot be undone.')) return;
this.cancellingId = requestId;
try {
const res = await fetch('/ItRequestAPI/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, reason: 'User requested cancellation' })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data?.message || 'Cancel failed');
await this.fetchData();
} catch (e) {
alert('Error: ' + (e.message || e));
} finally {
this.cancellingId = 0;
}
},
// ========= Section B (second table) =========
openSectionB(statusId) {
const here = window.location.pathname + window.location.search + window.location.hash;
const returnUrl = encodeURIComponent(here);
window.location.href = `/IT/ApprovalDashboard/SectionB?statusId=${statusId}&returnUrl=${returnUrl}`;
},
async acceptRequestor(statusId) {
try {
this.busy = true;
const res = await fetch('/ItRequestAPI/sectionB/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ statusId, by: 'REQUESTOR' })
});
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(j.message || 'Accept failed');
// refresh only this row's meta
await this.loadSectionBMeta([statusId]);
} catch (e) {
alert(e.message || 'Action failed');
} finally {
this.busy = false;
}
},
downloadPdf(statusId) { window.open(`/ItRequestAPI/sectionB/pdf?statusId=${statusId}`, '_blank'); },
// Derive requestor-centric stage
sbStageOf(sb) {
if (sb.itAccepted && sb.requestorAccepted) return 'complete';
if (sb.saved && !sb.requestorAccepted) return 'need';
if (sb.saved && sb.requestorAccepted && !sb.itAccepted) return 'waiting';
return 'notstarted';
},
// Load meta for all eligible Section B rows (or subset)
async loadSectionBMeta(whichStatusIds = null) {
try {
const targets = (whichStatusIds && whichStatusIds.length)
? whichStatusIds
: this.allRows
.filter(r => this.SECTIONB_ALLOW_STATUSES.includes(r.overallStatus || ''))
.map(r => r.statusId);
if (!targets.length) { this.sbLoaded = true; return; }
for (const sid of targets) {
const res = await fetch(`/ItRequestAPI/sectionB/meta?statusId=${sid}`);
if (!res.ok) continue;
const j = await res.json().catch(() => ({}));
this.sbMetaMap[sid] = {
saved: !!j.sectionB?.saved,
requestorAccepted: !!j.requestorAccepted,
itAccepted: !!j.itAccepted,
lastEditedBy: j.sectionB?.lastEditedBy || null
};
}
this.sbLoaded = true;
} catch {
this.sbLoaded = true;
}
},
setSbSubTab(tab) {
this.sbSubTab = tab;
this.sectionBPageIndex = 1;
}
},
mounted() { this.fetchData(); }
});
app.mount('#myReqApp');
</script>
}

View File

@ -1,632 +0,0 @@
@{
ViewData["Title"] = "IT Request Review";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<style>
:root{
--card-radius:16px;
--soft-shadow:0 8px 24px rgba(0,0,0,.08);
--soft-border:#eef2f6;
--chip-pending:#ffe599; /* soft amber */
--chip-approved:#b7e1cd; /* soft green */
--chip-rejected:#f8b4b4; /* soft red */
--text-muted:#6b7280;
}
/* Shell */
#reviewApp{
max-width: 1200px;
margin: auto;
font-size: 14px;
}
/* Page header */
.page-head{
display:flex; align-items:center; justify-content:space-between;
gap:1rem; margin-bottom:1rem;
}
.page-title{
display:flex; align-items:center; gap:.75rem; margin:0;
font-weight:700; letter-spacing:.2px;
}
.subtle{
color:var(--text-muted);
font-weight:500;
}
/* Card */
.ui-card{
background:#fff; border-radius:var(--card-radius);
box-shadow:var(--soft-shadow); border:1px solid var(--soft-border);
overflow:hidden; margin-bottom:18px;
}
.ui-card-head{
display:flex; align-items:center; justify-content:space-between;
padding:14px 18px; border-bottom:1px solid var(--soft-border);
background:linear-gradient(180deg,#fbfdff, #f7fafc);
}
.ui-card-head h6{ margin:0; font-weight:700; color:#0b5ed7; }
.ui-card-body{ padding:16px 18px; }
/* Requester grid */
.req-grid{
display:grid; grid-template-columns:repeat(2,1fr);
gap:10px 18px;
}
@@media (max-width:768px){ .req-grid{ grid-template-columns:1fr; } }
.req-line b{ color:#111827; }
.req-line span{ color:var(--text-muted); }
/* Chips */
.chip{
display:inline-flex; align-items:center; gap:.4rem;
padding:.3rem .6rem; border-radius:999px; font-weight:600; font-size:12px;
border:1px solid rgba(0,0,0,.05);
}
.chip i{ font-size:14px; }
.chip-pending{ background:var(--chip-pending); }
.chip-approved{ background:var(--chip-approved); }
.chip-rejected{ background:var(--chip-rejected); }
.chip-muted{ background:#e5e7eb; }
/* Tables */
.nice-table{
width:100%; border-collapse:separate; border-spacing:0;
overflow:hidden; border-radius:12px; border:1px solid var(--soft-border);
}
.nice-table thead th{
background:#f3f6fb; color:#334155; font-weight:700; font-size:12px;
text-transform:uppercase; letter-spacing:.4px; border-bottom:1px solid var(--soft-border);
}
.nice-table th, .nice-table td{ padding:10px 12px; vertical-align:middle; }
.nice-table tbody tr + tr td{ border-top:1px solid #f1f5f9; }
.nice-table tbody tr:hover{ background:#fafcff; }
/* Boolean badges in table */
.yes-badge, .no-badge{
display:inline-block; padding:.25rem .5rem; font-size:12px; font-weight:700;
border-radius:999px;
}
.yes-badge{ background:#e7f7ed; color:#166534; border:1px solid #c7ecd3;}
.no-badge{ background:#eef2f7; color:#334155; border:1px solid #e1e7ef;}
/* Empty state */
.empty{
text-align:center; padding:18px; color:var(--text-muted);
}
.empty i{ display:block; font-size:22px; margin-bottom:6px; opacity:.7; }
/* Skeletons */
.skeleton{ position:relative; background:#f1f5f9; overflow:hidden; border-radius:6px; }
.skeleton::after{
content:""; position:absolute; inset:0;
background:linear-gradient(90deg, transparent, rgba(255,255,255,.6), transparent);
animation: shimmer 1.2s infinite;
transform: translateX(-100%);
}
@@keyframes shimmer{
0%{ transform:translateX(-100%); }
100%{ transform:translateX(100%); }
}
/* Sticky action bar */
.action-bar{
position:sticky; bottom:12px; z-index:5;
display:flex; justify-content:flex-end; gap:10px;
padding:12px; border-radius:12px; background:rgba(255,255,255,.8);
backdrop-filter: blur(6px);
border:1px solid var(--soft-border); box-shadow:var(--soft-shadow);
margin-top:8px;
}
/* Soft buttons */
.btn-soft{
border-radius:10px; padding:.55rem .9rem; font-weight:700; letter-spacing:.2px;
border:1px solid transparent; box-shadow:0 2px 8px rgba(0,0,0,.06);
}
.btn-approve{ background:#22c55e; color:#fff; }
.btn-approve:hover{ background:#16a34a; }
.btn-reject{ background:#ef4444; color:#fff; }
.btn-reject:hover{ background:#dc2626; }
.btn-disabled{ background:#e5e7eb; color:#6b7280; cursor:not-allowed; }
</style>
<div id="reviewApp">
<!-- Header -->
<div class="page-head">
<h3 class="page-title">
</h3>
<div>
<span :class="overallChip.class">
<i :class="overallChip.icon"></i>
{{ overallChip.text }}
</span>
</div>
</div>
<!-- Requester Info -->
<div class="ui-card">
<div class="ui-card-head">
<h6><i class="bi bi-person-badge"></i> Requester Info</h6>
<small class="subtle" v-if="!isLoading">Submitted: {{ formatDate(userInfo.submitDate) }}</small>
<div v-else class="skeleton" style="height:14px; width:160px;"></div>
</div>
<div class="ui-card-body">
<div class="req-grid">
<div class="req-line">
<b>Name</b><br>
<span v-if="!isLoading">{{ userInfo.staffName || '—' }}</span>
<div v-else class="skeleton" style="height:14px;"></div>
</div>
<div class="req-line">
<b>Department</b><br>
<span v-if="!isLoading">{{ userInfo.departmentName || '—' }}</span>
<div v-else class="skeleton" style="height:14px;"></div>
</div>
<div class="req-line">
<b>Company</b><br>
<span v-if="!isLoading">{{ userInfo.companyName || '—' }}</span>
<div v-else class="skeleton" style="height:14px;"></div>
</div>
<div class="req-line">
<b>Designation</b><br>
<span v-if="!isLoading">{{ userInfo.designation || '—' }}</span>
<div v-else class="skeleton" style="height:14px;"></div>
</div>
</div>
</div>
</div>
<!-- Hardware -->
<div class="ui-card">
<div class="ui-card-head">
<h6><i class="bi bi-cpu"></i> Hardware Requested</h6>
</div>
<div class="ui-card-body">
<div v-if="isLoading">
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
<div class="skeleton" style="height:36px;"></div>
</div>
<div v-else>
<table class="nice-table table-striped">
<thead>
<tr>
<th>Category</th>
<th>Purpose</th>
<th>Justification</th>
<th>Other</th>
</tr>
</thead>
<tbody>
<tr v-for="item in hardware" :key="item.id">
<td>{{ item.category }}</td>
<td>{{ item.purpose }}</td>
<td>{{ item.justification }}</td>
<td>{{ item.otherDescription }}</td>
</tr>
<tr v-if="hardware.length === 0">
<td colspan="4" class="empty">
<i class="bi bi-inboxes"></i>
No hardware requested
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Email -->
<div class="ui-card">
<div class="ui-card-head">
<h6><i class="bi bi-envelope-paper"></i> Email Requests</h6>
</div>
<div class="ui-card-body">
<div v-if="isLoading">
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
<div class="skeleton" style="height:36px;"></div>
</div>
<div v-else>
<table class="nice-table table-striped">
<thead>
<tr>
<th>Purpose</th>
<th>Proposed Address</th>
</tr>
</thead>
<tbody>
<tr v-for="item in emails" :key="item.id">
<td>{{ item.purpose }}</td>
<td>{{ item.proposedAddress }}</td>
</tr>
<tr v-if="emails.length === 0">
<td colspan="2" class="empty">
<i class="bi bi-inboxes"></i>
No email requests
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- OS Requirements -->
<div class="ui-card">
<div class="ui-card-head">
<h6><i class="bi bi-windows"></i> Operating System Requirements</h6>
</div>
<div class="ui-card-body">
<div v-if="isLoading">
<div class="skeleton" style="height:36px;"></div>
</div>
<div v-else>
<table class="nice-table">
<thead>
<tr>
<th>Requirement</th>
</tr>
</thead>
<tbody>
<tr v-for="item in osreqs" :key="item.id">
<td>{{ item.requirementText }}</td>
</tr>
<tr v-if="osreqs.length === 0">
<td class="empty">
<i class="bi bi-inboxes"></i>
No OS requirements
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Software -->
<div class="ui-card">
<div class="ui-card-head">
<h6><i class="bi bi-boxes"></i> Software Requested</h6>
</div>
<div class="ui-card-body">
<div v-if="isLoading">
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
<div class="skeleton" style="height:36px;"></div>
</div>
<div v-else>
<table class="nice-table table-striped">
<thead>
<tr>
<th>Bucket</th>
<th>Name</th>
<th>Other</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr v-for="item in software" :key="item.id">
<td>{{ item.bucket }}</td>
<td>{{ item.name }}</td>
<td>{{ item.otherName }}</td>
<td>{{ item.notes }}</td>
</tr>
<tr v-if="software.length === 0">
<td colspan="4" class="empty">
<i class="bi bi-inboxes"></i>
No software requested
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Shared Permissions -->
<div class="ui-card">
<div class="ui-card-head">
<h6><i class="bi bi-folder-symlink"></i> Shared Folder / Permission Requests</h6>
</div>
<div class="ui-card-body">
<div v-if="isLoading">
<div class="skeleton" style="height:36px;"></div>
</div>
<div v-else>
<table class="nice-table">
<thead>
<tr>
<th>Share Name</th>
<th>Read</th>
<th>Write</th>
<th>Delete</th>
<th>Remove</th>
</tr>
</thead>
<tbody>
<tr v-for="item in sharedPerms" :key="item.id">
<td>{{ item.shareName }}</td>
<td><span :class="item.canRead ? 'yes-badge' : 'no-badge'">{{ item.canRead ? 'Yes' : 'No' }}</span></td>
<td><span :class="item.canWrite ? 'yes-badge' : 'no-badge'">{{ item.canWrite ? 'Yes' : 'No' }}</span></td>
<td><span :class="item.canDelete ? 'yes-badge' : 'no-badge'">{{ item.canDelete ? 'Yes' : 'No' }}</span></td>
<td><span :class="item.canRemove ? 'yes-badge' : 'no-badge'">{{ item.canRemove ? 'Yes' : 'No' }}</span></td>
</tr>
<tr v-if="sharedPerms.length === 0">
<td colspan="5" class="empty">
<i class="bi bi-inboxes"></i>
No shared permissions requested
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Approval Trail -->
<div class="ui-card">
<div class="ui-card-head">
<h6><i class="bi bi-flag"></i> Approval Trail</h6>
</div>
<div class="ui-card-body">
<div v-if="isLoading">
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
<div class="skeleton" style="height:36px; margin-bottom:10px;"></div>
<div class="skeleton" style="height:36px;"></div>
</div>
<div v-else>
<table class="nice-table">
<thead>
<tr>
<th>Stage</th>
<th>Status</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr>
<td>HOD</td>
<td><span :class="badgeChip(status.hodStatus).class"><i :class="badgeChip(status.hodStatus).icon"></i>{{ status.hodStatus || '—' }}</span></td>
<td>{{ formatDate(status.hodSubmitDate) || '—' }}</td>
</tr>
<tr>
<td>Group IT HOD</td>
<td><span :class="badgeChip(status.gitHodStatus).class"><i :class="badgeChip(status.gitHodStatus).icon"></i>{{ status.gitHodStatus || '—' }}</span></td>
<td>{{ formatDate(status.gitHodSubmitDate) || '—' }}</td>
</tr>
<tr>
<td>Finance HOD</td>
<td><span :class="badgeChip(status.finHodStatus).class"><i :class="badgeChip(status.finHodStatus).icon"></i>{{ status.finHodStatus || '—' }}</span></td>
<td>{{ formatDate(status.finHodSubmitDate) || '—' }}</td>
</tr>
<tr>
<td>Management</td>
<td><span :class="badgeChip(status.mgmtStatus).class"><i :class="badgeChip(status.mgmtStatus).icon"></i>{{ status.mgmtStatus || '—' }}</span></td>
<td>{{ formatDate(status.mgmtSubmitDate) || '—' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Sticky Action Bar -->
<div class="action-bar">
<template v-if="status.canApprove">
<button class="btn-soft btn-approve" @@click="updateStatus('Approved')">
<i class="bi bi-check2-circle"></i> Approve
</button>
<button class="btn-soft btn-reject" @@click="updateStatus('Rejected')">
<i class="bi bi-x-circle"></i> Reject
</button>
</template>
<template v-else>
<button class="btn-soft btn-disabled" disabled>
<i class="bi bi-shield-lock"></i> You cannot act on this request right now
</button>
</template>
</div>
</div>
</div>
</div>
<script>
const reviewApp = Vue.createApp({
data() {
return {
isLoading: true,
userInfo: {
staffName: "", departmentName: "", companyName: "",
designation: "", submitDate: ""
},
hardware: [],
emails: [],
osreqs: [],
software: [],
sharedPerms: [],
status: {
hodStatus: "Pending", gitHodStatus: "Pending",
finHodStatus: "Pending", mgmtStatus: "Pending",
hodSubmitDate: "", gitHodSubmitDate: "", finHodSubmitDate: "", mgmtSubmitDate: "",
overallStatus: "Pending", canApprove: false
}
};
},
computed:{
overallChip(){
const s = (this.status.overallStatus || 'Pending').toLowerCase();
if(s==='approved') return { class:'chip chip-approved', icon:'bi bi-check2-circle', text:'Overall: Approved' };
if(s==='rejected') return { class:'chip chip-rejected', icon:'bi bi-x-circle', text:'Overall: Rejected' };
return { class:'chip chip-pending', icon:'bi bi-hourglass-split', text:`Overall: ${this.status.overallStatus || 'Pending'}` };
}
},
methods: {
badgeChip(v){
const s = (v || 'Pending').toLowerCase();
if(s==='approved') return { class:'chip chip-approved', icon:'bi bi-check2' };
if(s==='rejected') return { class:'chip chip-rejected', icon:'bi bi-x-lg' };
if(s==='pending') return { class:'chip chip-pending', icon:'bi bi-hourglass' };
return { class:'chip chip-muted', icon:'bi bi-dot' };
},
// ===== Load existing (full function) =====
async loadRequest() {
this.isLoading = true;
// 1) Read statusId from URL
const params = new URLSearchParams(window.location.search);
const statusId = params.get("statusId");
if (!statusId) {
alert("Missing statusId in URL");
this.isLoading = false;
return;
}
try {
// 2) Fetch request payload
const res = await fetch(`/ItRequestAPI/request/${statusId}`);
const ct = res.headers.get('content-type') || '';
let data, text;
if (ct.includes('application/json')) {
data = await res.json();
} else {
text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
if (!res.ok) throw new Error(data?.message || `HTTP ${res.status}`);
console.log("RequestReview raw payload:", data);
// 3) Requester / submit metadata
// Prefer top-level data.userInfo; if absent, fall back to data.request / data.Request
const reqSrc = (data.userInfo ?? data.request ?? data.Request ?? {});
// Many APIs place submit date under status or request; pick the first available
const submittedAt =
data.status?.submitDate ?? data.status?.SubmitDate ??
reqSrc.submitDate ?? reqSrc.SubmitDate ??
data.status?.firstSubmittedAt ?? data.status?.FirstSubmittedAt ??
data.request?.firstSubmittedAt ?? data.Request?.FirstSubmittedAt ?? "";
this.userInfo = {
staffName: reqSrc.staffName ?? reqSrc.StaffName ?? "",
departmentName: reqSrc.departmentName ?? reqSrc.DepartmentName ?? "",
companyName: reqSrc.companyName ?? reqSrc.CompanyName ?? "",
designation: reqSrc.designation ?? reqSrc.Designation ?? "",
submitDate: submittedAt
};
// 4) Hardware
this.hardware = (data.hardware ?? []).map(x => ({
id: x.id ?? x.Id,
category: x.category ?? x.Category ?? "",
purpose: x.purpose ?? x.Purpose ?? "",
justification: x.justification ?? x.Justification ?? "",
otherDescription: x.otherDescription ?? x.OtherDescription ?? ""
}));
// 5) Emails
this.emails = (data.emails ?? []).map(x => ({
id: x.id ?? x.Id,
purpose: x.purpose ?? x.Purpose ?? "",
proposedAddress: x.proposedAddress ?? x.ProposedAddress ?? ""
}));
// 6) OS requirements
this.osreqs = (data.osreqs ?? data.OSReqs ?? []).map(x => ({
id: x.id ?? x.Id,
requirementText: x.requirementText ?? x.RequirementText ?? ""
}));
// 7) Software
this.software = (data.software ?? []).map(x => ({
id: x.id ?? x.Id,
bucket: x.bucket ?? x.Bucket ?? "",
name: x.name ?? x.Name ?? "",
otherName: x.otherName ?? x.OtherName ?? "",
notes: x.notes ?? x.Notes ?? ""
}));
// 8) Shared permissions
this.sharedPerms = (data.sharedPerms ?? data.sharedPermissions ?? []).map(x => ({
id: x.id ?? x.Id,
shareName: x.shareName ?? x.ShareName ?? "",
canRead: (x.canRead ?? x.CanRead) || false,
canWrite: (x.canWrite ?? x.CanWrite) || false,
canDelete: (x.canDelete ?? x.CanDelete) || false,
canRemove: (x.canRemove ?? x.CanRemove) || false
}));
// 9) Status block
this.status = {
hodStatus: data.status?.hodStatus ?? data.status?.HodStatus ?? "Pending",
gitHodStatus: data.status?.gitHodStatus ?? data.status?.GitHodStatus ?? "Pending",
finHodStatus: data.status?.finHodStatus ?? data.status?.FinHodStatus ?? "Pending",
mgmtStatus: data.status?.mgmtStatus ?? data.status?.MgmtStatus ?? "Pending",
hodSubmitDate: data.status?.hodSubmitDate ?? data.status?.HodSubmitDate ?? "",
gitHodSubmitDate: data.status?.gitHodSubmitDate ?? data.status?.GitHodSubmitDate ?? "",
finHodSubmitDate: data.status?.finHodSubmitDate ?? data.status?.FinHodSubmitDate ?? "",
mgmtSubmitDate: data.status?.mgmtSubmitDate ?? data.status?.MgmtSubmitDate ?? "",
overallStatus: data.status?.overallStatus ?? data.status?.OverallStatus ?? "Pending",
canApprove: (data.status?.canApprove ?? data.status?.CanApprove) || false
};
const os = (this.status.overallStatus || '').toLowerCase();
if (os === 'cancelled' || os === 'draft') {
this.status.canApprove = false;
}
} catch (err) {
console.error("RequestReview fetch failed:", err);
alert(`Failed to load request: ${err.message}`);
} finally {
this.isLoading = false;
}
},
async updateStatus(decision) {
const params = new URLSearchParams(window.location.search);
const statusId = params.get("statusId");
try {
const res = await fetch(`/ItRequestAPI/approveReject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ statusId: parseInt(statusId), decision: decision })
});
const ct = res.headers.get('content-type') || '';
const payload = ct.includes('application/json') ? await res.json() : { message: await res.text() };
if (!res.ok) throw new Error(payload?.message || `HTTP ${res.status}`);
// Small UX pop
const verb = decision === 'Approved' ? 'approved' : 'rejected';
alert(`Request ${verb} successfully.`);
this.loadRequest();
} catch (e) {
console.error(e);
alert(`Failed to update status: ${e.message}`);
}
},
formatDate(dateStr) {
if (!dateStr) return '';
try{
// Show using local timezone, readable
return new Date(dateStr).toLocaleString();
}catch{ return dateStr; }
}
},
mounted() { this.loadRequest(); }
});
reviewApp.mount("#reviewApp");
</script>

View File

@ -1,360 +0,0 @@
@{
ViewData["Title"] = "IT Section B Asset Information";
Layout = "~/Views/Shared/_Layout.cshtml";
// Expect ?statusId=123
}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<style>
:root{ --card-r:16px; --soft-b:#eef2f6; --soft-s:0 8px 24px rgba(0,0,0,.08); --muted:#6b7280; }
#bApp{ max-width:1100px; margin:auto; font-size:14px; }
.ui-card{ background:#fff; border:1px solid var(--soft-b); border-radius:var(--card-r); box-shadow:var(--soft-s); margin-bottom:16px; overflow:hidden; }
.ui-head{ display:flex; align-items:center; justify-content:space-between; padding:14px 18px; border-bottom:1px solid var(--soft-b); background:linear-gradient(180deg,#fbfdff,#f7fafc); }
.ui-head h6{ margin:0; font-weight:800; color:#0b5ed7; }
.ui-body{ padding:16px 18px; }
.muted{ color:var(--muted); }
.grid2{ display:grid; grid-template-columns:1fr 1fr; gap:12px; }
@@media (max-width:768px){ .grid2{ grid-template-columns:1fr; } }
.readonly-pill{ display:inline-flex; align-items:center; gap:.4rem; padding:.28rem .6rem; border-radius:999px; background:#eef2f7; color:#334155; font-weight:700; font-size:12px; }
.sig-box{ border:1px dashed #d1d5db; border-radius:8px; padding:12px; background:#fbfbfc; }
.submit-bar{ position:sticky; bottom:12px; z-index:5; display:flex; justify-content:flex-end; gap:10px; padding:12px; border-radius:12px; background:rgba(255,255,255,.85); backdrop-filter:blur(6px); border:1px solid var(--soft-b); box-shadow:var(--soft-s); margin:6px 0 30px; }
</style>
<div id="bApp">
<!-- Page head -->
<div class="d-flex align-items-center justify-content-between my-3">
<h5 class="m-0 fw-bold">Section B Asset Information</h5>
<div class="d-flex align-items-center gap-2">
<span class="readonly-pill">Overall: {{ meta.overallStatus || '—' }}</span>
<span class="readonly-pill">Requestor: {{ meta.requestorName || '—' }}</span>
<span class="readonly-pill" v-if="meta.isItMember">You are IT</span>
<span class="readonly-pill" :class="meta.sectionBSent ? '' : 'bg-warning-subtle'">
{{ meta.sectionBSent ? 'Sent' : 'Draft' }}
</span>
</div>
</div>
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
<div v-if="busy" class="alert alert-secondary py-2">Loading…</div>
<!-- Gate -->
<div v-if="!busy && meta.overallStatus !== 'Approved'" class="alert alert-warning">
Section B is available only after the request is <strong>Approved</strong>. Current status: <strong>{{ meta.overallStatus || '—' }}</strong>.
</div>
<template v-if="!busy && meta.overallStatus === 'Approved'">
<!-- Asset Information -->
<div class="ui-card">
<div class="ui-head">
<h6><i class="bi bi-hdd-stack"></i> Asset Information</h6>
<span class="muted" v-if="meta.lastEditedBy">
Last edited by {{ meta.lastEditedBy }} at {{ fmtDT(meta.lastEditedAt) }}
</span>
</div>
<div class="ui-body">
<div v-if="!meta.isItMember" class="alert alert-info py-2">
Youre 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>
}

View File

@ -1,321 +0,0 @@
@{
ViewData["Title"] = "Edit Section B Asset Information";
Layout = "~/Views/Shared/_Layout.cshtml";
// ?statusId=123
}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<style>
:root{ --card-r:16px; --soft-b:#eef2f6; --soft-s:0 8px 24px rgba(0,0,0,.08); --muted:#6b7280; }
#sbEditApp{ max-width:1100px; margin:auto; font-size:14px; }
.ui-card{ background:#fff; border:1px solid var(--soft-b); border-radius:var(--card-r); box-shadow:var(--soft-s); margin-bottom:16px; overflow:hidden; }
.ui-head{ display:flex; align-items:center; justify-content:space-between; padding:14px 18px; border-bottom:1px solid var(--soft-b); background:linear-gradient(180deg,#fbfdff,#f7fafc); }
.ui-head h6{ margin:0; font-weight:800; color:#0b5ed7; }
.ui-body{ padding:16px 18px; }
.muted{ color:var(--muted); }
.grid2{ display:grid; grid-template-columns:1fr 1fr; gap:12px; }
@@media (max-width:768px){ .grid2{ grid-template-columns:1fr; } }
.readonly-pill{ display:inline-flex; align-items:center; gap:.4rem; padding:.28rem .6rem; border-radius:999px; background:#eef2f7; color:#334155; font-weight:700; font-size:12px; }
.sig-box{ border:1px dashed #d1d5db; border-radius:8px; padding:12px; background:#fbfbfc; }
.submit-bar{ position:sticky; bottom:12px; z-index:5; display:flex; justify-content:flex-end; gap:10px; padding:12px; border-radius:12px; background:rgba(255,255,255,.85); backdrop-filter:blur(6px); border:1px solid var(--soft-b); box-shadow:var(--soft-s); margin:6px 0 30px; }
</style>
<div id="sbEditApp">
<!-- Page head -->
<div class="d-flex align-items-center justify-content-between my-3">
<h5 class="m-0 fw-bold">Edit Section B Asset Information</h5>
<div class="d-flex align-items-center gap-2">
<span class="readonly-pill">Overall: {{ meta.overallStatus || '—' }}</span>
<span class="readonly-pill">Requestor: {{ meta.requestorName || '—' }}</span>
<span class="readonly-pill" v-if="meta.isItMember">You are IT</span>
<span class="readonly-pill" :class="meta.sectionBSent ? '' : 'bg-warning-subtle'">
{{ meta.sectionBSent ? 'Sent' : 'Draft' }}
</span>
</div>
</div>
<div v-if="error" class="alert alert-danger py-2">{{ error }}</div>
<div v-if="busy" class="alert alert-secondary py-2">Loading…</div>
<!-- Gate -->
<div v-if="!busy && meta.overallStatus !== 'Approved'" class="alert alert-warning">
Section B is available only after the request is <strong>Approved</strong>. Current status: <strong>{{ meta.overallStatus || '—' }}</strong>.
</div>
<template v-if="!busy && meta.overallStatus === 'Approved'">
<!-- Asset info -->
<div class="ui-card">
<div class="ui-head">
<h6><i class="bi bi-hdd-stack"></i> Asset Information</h6>
<span class="muted" v-if="meta.lastEditedBy">
Last edited by {{ meta.lastEditedBy }} at {{ fmtDT(meta.lastEditedAt) }}
</span>
</div>
<div class="ui-body">
<div v-if="!meta.isItMember" class="alert alert-info py-2">
Youre 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>
}

View File

@ -1,83 +0,0 @@
@page
@model AccessDeniedModel
@{
ViewData["Title"] = "Access denied";
@inject UserManager<UserModel> _userManager
var user = await _userManager.GetUserAsync(User);
if (user != null)
{
var userComDept = user.departmentId;
var userRole = await _userManager.GetRolesAsync(user);
}
}
<header id="deniedHeader">
<template v-if="ldapUserInfo.role.length == 0"><p class="text-danger">You do not have access to this resource because you have no role. Please contact the system administrator.</p></template>
<template v-else><p class="text-danger">You do not have access to this resource.</p></template>
</header>
@section Scripts {
<script>
if (typeof jQuery === 'undefined') {
console.error('jQuery is not loaded.');
}
$(function () {
app.mount('#deniedHeader');
});
const app = Vue.createApp({
data() {
return {
ldapUserInfo: {
role: [],
},
};
},
mounted() {
this.getUserInfo();
},
watch: {
},
methods: {
async getUserInfo() {
try {
// Show the loading modal
$('#loadingModal').modal('show');
// Perform the fetch request
const response = await fetch('/IdentityAPI/GetUserInformation', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
// Check if the response is OK
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message);
}
const data = await response.json();
if (data.userInfo) {
console.log(data.userInfo)
this.ldapUserInfo = data.userInfo
} else {
console.error('Get user failed:', data);
}
}
catch (error) {
console.error('Error getting user information:', error);
}
finally {
await new Promise(resolve => {
$('#loadingModal').on('shown.bs.modal', resolve);
});
$('#loadingModal').modal('hide');
}
},
},
});
</script>
}

View File

@ -1,23 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class AccessDeniedModel : PageModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public void OnGet()
{
}
}
}

View File

@ -1,8 +0,0 @@
@page
@model ConfirmEmailModel
@{
ViewData["Title"] = "Confirm email";
}
<h1>@ViewData["Title"]</h1>
<partial name="_StatusMessage" model="Model.StatusMessage" />

View File

@ -1,52 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
{
public class ConfirmEmailModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
public ConfirmEmailModel(UserManager<UserModel> userManager)
{
_userManager = userManager;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
public async Task<IActionResult> OnGetAsync(string userId, string code)
{
if (userId == null || code == null)
{
return RedirectToPage("/Index");
}
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
{
return NotFound($"Unable to load user with ID '{userId}'.");
}
code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
var result = await _userManager.ConfirmEmailAsync(user, code);
StatusMessage = result.Succeeded ? "Thank you for confirming your email." : "Error confirming your email.";
return Page();
}
}
}

View File

@ -1,8 +0,0 @@
@page
@model ConfirmEmailChangeModel
@{
ViewData["Title"] = "Confirm email change";
}
<h1>@ViewData["Title"]</h1>
<partial name="_StatusMessage" model="Model.StatusMessage" />

View File

@ -1,70 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
{
public class ConfirmEmailChangeModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly SignInManager<UserModel> _signInManager;
public ConfirmEmailChangeModel(UserManager<UserModel> userManager, SignInManager<UserModel> signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
public async Task<IActionResult> OnGetAsync(string userId, string email, string code)
{
if (userId == null || email == null || code == null)
{
return RedirectToPage("/Index");
}
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
{
return NotFound($"Unable to load user with ID '{userId}'.");
}
code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
var result = await _userManager.ChangeEmailAsync(user, email, code);
if (!result.Succeeded)
{
StatusMessage = "Error changing email.";
return Page();
}
// In our UI email and user name are one and the same, so when we update the email
// we need to update the user name.
var setUserNameResult = await _userManager.SetUserNameAsync(user, email);
if (!setUserNameResult.Succeeded)
{
StatusMessage = "Error changing user name.";
return Page();
}
await _signInManager.RefreshSignInAsync(user);
StatusMessage = "Thank you for confirming your email change.";
return Page();
}
}
}

View File

@ -1,33 +0,0 @@
@page
@model ExternalLoginModel
@{
ViewData["Title"] = "Register";
}
<h1>@ViewData["Title"]</h1>
<h2 id="external-login-title">Associate your @Model.ProviderDisplayName account.</h2>
<hr />
<p id="external-login-description" class="text-info">
You've successfully authenticated with <strong>@Model.ProviderDisplayName</strong>.
Please enter an email address for this site below and click the Register button to finish
logging in.
</p>
<div class="row">
<div class="col-md-4">
<form asp-page-handler="Confirmation" asp-route-returnUrl="@Model.ReturnUrl" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.Email" class="form-control" autocomplete="email" placeholder="Please enter your email."/>
<label asp-for="Input.Email" class="form-label"></label>
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -1,224 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
{
[AllowAnonymous]
public class ExternalLoginModel : PageModel
{
private readonly SignInManager<UserModel> _signInManager;
private readonly UserManager<UserModel> _userManager;
private readonly IUserStore<UserModel> _userStore;
private readonly IUserEmailStore<UserModel> _emailStore;
private readonly IEmailSender _emailSender;
private readonly ILogger<ExternalLoginModel> _logger;
public ExternalLoginModel(
SignInManager<UserModel> signInManager,
UserManager<UserModel> userManager,
IUserStore<UserModel> userStore,
ILogger<ExternalLoginModel> logger,
IEmailSender emailSender)
{
_signInManager = signInManager;
_userManager = userManager;
_userStore = userStore;
_emailStore = GetEmailStore();
_logger = logger;
_emailSender = emailSender;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string ProviderDisplayName { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string ReturnUrl { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string ErrorMessage { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[EmailAddress]
public string Email { get; set; }
}
public IActionResult OnGet() => RedirectToPage("./Login");
public IActionResult OnPost(string provider, string returnUrl = null)
{
// Request a redirect to the external login provider.
var redirectUrl = Url.Page("./ExternalLogin", pageHandler: "Callback", values: new { returnUrl });
var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
return new ChallengeResult(provider, properties);
}
public async Task<IActionResult> OnGetCallbackAsync(string returnUrl = null, string remoteError = null)
{
returnUrl = returnUrl ?? Url.Content("~/");
if (remoteError != null)
{
ErrorMessage = $"Error from external provider: {remoteError}";
return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
}
var info = await _signInManager.GetExternalLoginInfoAsync();
if (info == null)
{
ErrorMessage = "Error loading external login information.";
return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
}
// Sign in the user with this external login provider if the user already has a login.
var result = await _signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false, bypassTwoFactor: true);
if (result.Succeeded)
{
_logger.LogInformation("{CompanyName} logged in with {LoginProvider} provider.", info.Principal.Identity.Name, info.LoginProvider);
return LocalRedirect(returnUrl);
}
if (result.IsLockedOut)
{
return RedirectToPage("./Lockout");
}
else
{
// If the user does not have an account, then ask the user to create an account.
ReturnUrl = returnUrl;
ProviderDisplayName = info.ProviderDisplayName;
if (info.Principal.HasClaim(c => c.Type == ClaimTypes.Email))
{
Input = new InputModel
{
Email = info.Principal.FindFirstValue(ClaimTypes.Email)
};
}
return Page();
}
}
public async Task<IActionResult> OnPostConfirmationAsync(string returnUrl = null)
{
returnUrl = returnUrl ?? Url.Content("~/");
// Get the information about the user from the external login provider
var info = await _signInManager.GetExternalLoginInfoAsync();
if (info == null)
{
ErrorMessage = "Error loading external login information during confirmation.";
return RedirectToPage("./Login", new { ReturnUrl = returnUrl });
}
if (ModelState.IsValid)
{
var user = CreateUser();
await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
var result = await _userManager.CreateAsync(user);
if (result.Succeeded)
{
result = await _userManager.AddLoginAsync(user, info);
if (result.Succeeded)
{
_logger.LogInformation("User created an account using {CompanyName} provider.", info.LoginProvider);
var userId = await _userManager.GetUserIdAsync(user);
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = Url.Page(
"/Account/ConfirmEmail",
pageHandler: null,
values: new { area = "Identity", userId = userId, code = code },
protocol: Request.Scheme);
await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
// If account confirmation is required, we need to show the link if we don't have a real email sender
if (_userManager.Options.SignIn.RequireConfirmedAccount)
{
return RedirectToPage("./RegisterConfirmation", new { Email = Input.Email });
}
await _signInManager.SignInAsync(user, isPersistent: false, info.LoginProvider);
return LocalRedirect(returnUrl);
}
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
}
ProviderDisplayName = info.ProviderDisplayName;
ReturnUrl = returnUrl;
return Page();
}
private UserModel CreateUser()
{
try
{
return Activator.CreateInstance<UserModel>();
}
catch
{
throw new InvalidOperationException($"Can't create an instance of '{nameof(UserModel)}'. " +
$"Ensure that '{nameof(UserModel)}' is not an abstract class and has a parameterless constructor, or alternatively " +
$"override the external login page in /Areas/Identity/Pages/Account/ExternalLogin.cshtml");
}
}
private IUserEmailStore<UserModel> GetEmailStore()
{
if (!_userManager.SupportsUserEmail)
{
throw new NotSupportedException("The default UI requires a user store with email support.");
}
return (IUserEmailStore<UserModel>)_userStore;
}
}
}

View File

@ -1,25 +0,0 @@
@page
@model ForgotPasswordModel
@{
ViewData["Title"] = "Forgot your password?";
}
<h2>Enter your email.</h2>
<hr />
<div class="row">
<div class="col-md-4">
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
<label asp-for="Input.Email" class="form-label"></label>
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Reset Password</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -1,85 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
{
public class ForgotPasswordModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly IEmailSender _emailSender;
public ForgotPasswordModel(UserManager<UserModel> userManager, IEmailSender emailSender)
{
_userManager = userManager;
_emailSender = emailSender;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[EmailAddress]
public string Email { get; set; }
}
public async Task<IActionResult> OnPostAsync()
{
if (ModelState.IsValid)
{
var user = await _userManager.FindByEmailAsync(Input.Email);
if (user == null || !(await _userManager.IsEmailConfirmedAsync(user)))
{
// Don't reveal that the user does not exist or is not confirmed
return RedirectToPage("./ForgotPasswordConfirmation");
}
// For more information on how to enable account confirmation and password reset please
// visit https://go.microsoft.com/fwlink/?LinkID=532713
var code = await _userManager.GeneratePasswordResetTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = Url.Page(
"/Account/ResetPassword",
pageHandler: null,
values: new { area = "Identity", code },
protocol: Request.Scheme);
await _emailSender.SendEmailAsync(
Input.Email,
"Reset Password",
$"Please reset your password by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
return RedirectToPage("./ForgotPasswordConfirmation");
}
return Page();
}
}
}

View File

@ -1,9 +0,0 @@
@page
@model ForgotPasswordConfirmation
@{
ViewData["Title"] = "Forgot password confirmation";
}
<p>
Please check your email to reset your password.
</p>

View File

@ -1,25 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[AllowAnonymous]
public class ForgotPasswordConfirmation : PageModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public void OnGet()
{
}
}
}

View File

@ -1,10 +0,0 @@
@page
@model LockoutModel
@{
ViewData["Title"] = "Locked out";
}
<header>
<h1 class="text-danger">@ViewData["Title"]</h1>
<p class="text-danger">This account has been locked out, please try again later.</p>
</header>

View File

@ -1,25 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[AllowAnonymous]
public class LockoutModel : PageModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public void OnGet()
{
}
}
}

View File

@ -1,188 +0,0 @@
@page
@model LoginModel
@{
ViewData["Title"] = "Log in";
}
<div class="row" id="systemLogin">
<div class="row">
<h2><label class="col-md-2">Login Type</label></h2>
<div class="btn-group col-md-4" role="group" aria-label="Login type">
<input type="radio" class="btn-check" name="loginType" id="local-login" value="Local" v-model="loginType">
<label class="btn btn-outline-primary" for="local-login">Local</label>
<input type="radio" class="btn-check" name="loginType" id="ad-login" value="AD" v-model="loginType" checked>
<label class="btn btn-outline-primary" for="ad-login">AD</label>
</div>
</div>
<div class="row">
<div class="col-md-4" v-if="loginType == 'Local'">
<form id="account" method="post">
<h2>Use a local account to log in.</h2>
<hr />
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
<label asp-for="Input.Email" class="form-label">Email</label>
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<div class="form-floating mb-3">
<input asp-for="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="password" />
<label asp-for="Input.Password" class="form-label">Password</label>
<span asp-validation-for="Input.Password" class="text-danger"></span>
</div>
<div>
<button id="login-submit" type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
</div>
</form>
</div>
<div class="col-md-4" v-if="loginType == 'AD'">
<form v-on:submit.prevent="ldapLogin" id="login" method="post">
<h2>Use a AD account to log in.</h2>
<hr />
<div class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input v-model="ldapLoginInfo.username" id="ldapUsername" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
<label id="ldapEmailLabel" class="form-label">Windows Login</label>
<span id="ldapEmailError" class="text-danger"></span>
</div>
<div class="form-floating mb-3">
<input v-model="ldapLoginInfo.password" id="ldapPassword" class="form-control" type="password" autocomplete="current-password" aria-required="true" placeholder="password" />
<label id="ldapPasswordLabel" class="form-label">Password</label>
<span id="ldapPasswordError" class="text-danger"></span>
</div>
<div>
<button id="ldap-login-submit" type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
</div>
</form>
</div>
<div class="col-md-6 col-md-offset-2">
<section>
<h3>Use another service to log in.</h3>
<hr />
@{
if ((Model.ExternalLogins?.Count ?? 0) == 0)
{
<div>
<p>
There are no external authentication services configured. See this <a href="https://go.microsoft.com/fwlink/?LinkID=532715">article
about setting up this ASP.NET application to support logging in via external services</a>.
</p>
</div>
}
else
{
<form id="external-account" asp-page="./ExternalLogin" asp-route-returnUrl="@Model.ReturnUrl" method="post" class="form-horizontal">
<div>
<p>
@foreach (var provider in Model.ExternalLogins!)
{
<button type="submit" class="btn btn-primary" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
}
</p>
</div>
</form>
}
}
</section>
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
<script>
$(function () {
app.mount('#systemLogin');
$('.closeModal').on('click', function () {
$('.modal').modal('hide');
});
});
const app = Vue.createApp({
data() {
return {
loginType: 'AD',
ldapLoginInfo: {
username: '',
password: '',
},
};
},
mounted() {
},
watch: {
},
methods: {
async ldapLogin() {
try {
// Show the loading modal
$('#loadingModal').modal('show');
// Perform the fetch request
const response = await fetch('/IdentityAPI/LdapLogin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(this.ldapLoginInfo),
});
// Check if the response is OK
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message);
}
const data = await response.json();
// Redirect if a URL is provided
if (data.redirectUrl) {
window.location.href = data.redirectUrl;
} else {
console.error('Login failed:', data);
alert('Login failed.');
}
}
catch (error) {
console.error('Error during LDAP login:', error);
alert(error.message);
}
finally {
await new Promise(resolve => {
$('#loadingModal').on('shown.bs.modal', resolve);
});
$('#loadingModal').modal('hide');
}
},
async fetchControllerMethodList() {
try {
const response = await fetch('/AdminAPI/GetListClassAndMethodInformation', {
method: 'POST',
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
// Assign data if it exists
if (data) {
this.controllerMethodData = data;
}
} catch (error) {
console.error('There was a problem with the fetch operation:', error);
}
},
},
});
</script>
}

View File

@ -1,141 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
{
public class LoginModel : PageModel
{
private readonly SignInManager<UserModel> _signInManager;
private readonly ILogger<LoginModel> _logger;
public LoginModel(SignInManager<UserModel> signInManager, ILogger<LoginModel> logger)
{
_signInManager = signInManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public IList<AuthenticationScheme> ExternalLogins { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string ReturnUrl { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string ErrorMessage { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[EmailAddress]
public string Email { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[DataType(DataType.Password)]
public string Password { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Display(Name = "Remember me?")]
public bool RememberMe { get; set; }
}
public async Task OnGetAsync(string returnUrl = null)
{
if (!string.IsNullOrEmpty(ErrorMessage))
{
ModelState.AddModelError(string.Empty, ErrorMessage);
}
returnUrl ??= Url.Content("~/");
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
ReturnUrl = returnUrl;
}
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
returnUrl ??= Url.Content("~/");
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
if (ModelState.IsValid)
{
// This doesn't count login failures towards account lockout
// To enable password failures to trigger account lockout, set lockoutOnFailure: true
var result = await _signInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false);
if (result.Succeeded)
{
_logger.LogInformation("User logged in.");
return LocalRedirect(returnUrl);
}
if (result.RequiresTwoFactor)
{
return RedirectToPage("./LoginWith2fa", new { ReturnUrl = returnUrl, RememberMe = Input.RememberMe });
}
if (result.IsLockedOut)
{
_logger.LogWarning("User account locked out.");
return RedirectToPage("./Lockout");
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return Page();
}
}
// If we got this far, something failed, redisplay form
return Page();
}
}
}

View File

@ -1,39 +0,0 @@
@page
@model LoginWith2faModel
@{
ViewData["Title"] = "Two-factor authentication";
}
<h1>@ViewData["Title"]</h1>
<hr />
<p>Your login is protected with an authenticator app. Enter your authenticator code below.</p>
<div class="row">
<div class="col-md-4">
<form method="post" asp-route-returnUrl="@Model.ReturnUrl">
<input asp-for="RememberMe" type="hidden" />
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.TwoFactorCode" class="form-control" autocomplete="off" />
<label asp-for="Input.TwoFactorCode" class="form-label"></label>
<span asp-validation-for="Input.TwoFactorCode" class="text-danger"></span>
</div>
<div class="checkbox mb-3">
<label asp-for="Input.RememberMachine" class="form-label">
<input asp-for="Input.RememberMachine" />
@Html.DisplayNameFor(m => m.Input.RememberMachine)
</label>
</div>
<div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
</div>
</form>
</div>
</div>
<p>
Don't have access to your authenticator device? You can
<a id="recovery-code-login" asp-page="./LoginWithRecoveryCode" asp-route-returnUrl="@Model.ReturnUrl">log in with a recovery code</a>.
</p>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -1,132 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
{
public class LoginWith2faModel : PageModel
{
private readonly SignInManager<UserModel> _signInManager;
private readonly UserManager<UserModel> _userManager;
private readonly ILogger<LoginWith2faModel> _logger;
public LoginWith2faModel(
SignInManager<UserModel> signInManager,
UserManager<UserModel> userManager,
ILogger<LoginWith2faModel> logger)
{
_signInManager = signInManager;
_userManager = userManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public bool RememberMe { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string ReturnUrl { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Text)]
[Display(Name = "Authenticator code")]
public string TwoFactorCode { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Display(Name = "Remember this machine")]
public bool RememberMachine { get; set; }
}
public async Task<IActionResult> OnGetAsync(bool rememberMe, string returnUrl = null)
{
// Ensure the user has gone through the username & password screen first
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new InvalidOperationException($"Unable to load two-factor authentication user.");
}
ReturnUrl = returnUrl;
RememberMe = rememberMe;
return Page();
}
public async Task<IActionResult> OnPostAsync(bool rememberMe, string returnUrl = null)
{
if (!ModelState.IsValid)
{
return Page();
}
returnUrl = returnUrl ?? Url.Content("~/");
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new InvalidOperationException($"Unable to load two-factor authentication user.");
}
var authenticatorCode = Input.TwoFactorCode.Replace(" ", string.Empty).Replace("-", string.Empty);
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, Input.RememberMachine);
var userId = await _userManager.GetUserIdAsync(user);
if (result.Succeeded)
{
_logger.LogInformation("User with ID '{UserId}' logged in with 2fa.", user.Id);
return LocalRedirect(returnUrl);
}
else if (result.IsLockedOut)
{
_logger.LogWarning("User with ID '{UserId}' account locked out.", user.Id);
return RedirectToPage("./Lockout");
}
else
{
_logger.LogWarning("Invalid authenticator code entered for user with ID '{UserId}'.", user.Id);
ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
return Page();
}
}
}
}

View File

@ -1,29 +0,0 @@
@page
@model LoginWithRecoveryCodeModel
@{
ViewData["Title"] = "Recovery code verification";
}
<h1>@ViewData["Title"]</h1>
<hr />
<p>
You have requested to log in with a recovery code. This login will not be remembered until you provide
an authenticator app code at log in or disable 2FA and log in again.
</p>
<div class="row">
<div class="col-md-4">
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.RecoveryCode" class="form-control" autocomplete="off" placeholder="RecoveryCode" />
<label asp-for="Input.RecoveryCode" class="form-label"></label>
<span asp-validation-for="Input.RecoveryCode" class="text-danger"></span>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Log in</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -1,113 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
{
public class LoginWithRecoveryCodeModel : PageModel
{
private readonly SignInManager<UserModel> _signInManager;
private readonly UserManager<UserModel> _userManager;
private readonly ILogger<LoginWithRecoveryCodeModel> _logger;
public LoginWithRecoveryCodeModel(
SignInManager<UserModel> signInManager,
UserManager<UserModel> userManager,
ILogger<LoginWithRecoveryCodeModel> logger)
{
_signInManager = signInManager;
_userManager = userManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string ReturnUrl { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
[Required]
[DataType(DataType.Text)]
[Display(Name = "Recovery Code")]
public string RecoveryCode { get; set; }
}
public async Task<IActionResult> OnGetAsync(string returnUrl = null)
{
// Ensure the user has gone through the username & password screen first
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new InvalidOperationException($"Unable to load two-factor authentication user.");
}
ReturnUrl = returnUrl;
return Page();
}
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
if (!ModelState.IsValid)
{
return Page();
}
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new InvalidOperationException($"Unable to load two-factor authentication user.");
}
var recoveryCode = Input.RecoveryCode.Replace(" ", string.Empty);
var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
var userId = await _userManager.GetUserIdAsync(user);
if (result.Succeeded)
{
_logger.LogInformation("User with ID '{UserId}' logged in with a recovery code.", user.Id);
return LocalRedirect(returnUrl ?? Url.Content("~/"));
}
if (result.IsLockedOut)
{
_logger.LogWarning("User account locked out.");
return RedirectToPage("./Lockout");
}
else
{
_logger.LogWarning("Invalid recovery code entered for user with ID '{UserId}' ", user.Id);
ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
return Page();
}
}
}
}

View File

@ -1,21 +0,0 @@
@page
@model LogoutModel
@{
ViewData["Title"] = "Log out";
}
<header>
<h1>@ViewData["Title"]</h1>
@{
if (User.Identity?.IsAuthenticated ?? false)
{
<form class="form-inline" asp-area="Identity" asp-page="/Account/Logout" asp-route-returnUrl="@Url.Page("/", new { area = "" })" method="post">
<button type="submit" class="nav-link btn btn-link text-dark">Click here to Logout</button>
</form>
}
else
{
<p>You have successfully logged out of the application.</p>
}
}
</header>

View File

@ -1,43 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
{
public class LogoutModel : PageModel
{
private readonly SignInManager<UserModel> _signInManager;
private readonly ILogger<LogoutModel> _logger;
public LogoutModel(SignInManager<UserModel> signInManager, ILogger<LogoutModel> logger)
{
_signInManager = signInManager;
_logger = logger;
}
public async Task<IActionResult> OnPost(string returnUrl = null)
{
await _signInManager.SignOutAsync();
_logger.LogInformation("User logged out.");
if (returnUrl != null)
{
return LocalRedirect(returnUrl);
}
else
{
// This needs to be a redirect so that the browser performs a new
// request and the identity for the user gets updated.
return RedirectToPage();
}
}
}
}

View File

@ -1,36 +0,0 @@
@page
@model ChangePasswordModel
@{
ViewData["Title"] = "Change password";
ViewData["ActivePage"] = ManageNavPages.ChangePassword;
}
<h3>@ViewData["Title"]</h3>
<partial name="_StatusMessage" for="StatusMessage" />
<div class="row">
<div class="col-md-6">
<form id="change-password-form" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.OldPassword" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Please enter your old password." />
<label asp-for="Input.OldPassword" class="form-label"></label>
<span asp-validation-for="Input.OldPassword" class="text-danger"></span>
</div>
<div class="form-floating mb-3">
<input asp-for="Input.NewPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please enter your new password." />
<label asp-for="Input.NewPassword" class="form-label"></label>
<span asp-validation-for="Input.NewPassword" class="text-danger"></span>
</div>
<div class="form-floating mb-3">
<input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your new password."/>
<label asp-for="Input.ConfirmPassword" class="form-label"></label>
<span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Update password</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -1,128 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
{
public class ChangePasswordModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly SignInManager<UserModel> _signInManager;
private readonly ILogger<ChangePasswordModel> _logger;
public ChangePasswordModel(
UserManager<UserModel> userManager,
SignInManager<UserModel> signInManager,
ILogger<ChangePasswordModel> logger)
{
_userManager = userManager;
_signInManager = signInManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[DataType(DataType.Password)]
[Display(Name = "Current password")]
public string OldPassword { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "New password")]
public string NewPassword { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[DataType(DataType.Password)]
[Display(Name = "Confirm new password")]
[Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
public string ConfirmPassword { get; set; }
}
public async Task<IActionResult> OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var hasPassword = await _userManager.HasPasswordAsync(user);
if (!hasPassword)
{
return RedirectToPage("./SetPassword");
}
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var changePasswordResult = await _userManager.ChangePasswordAsync(user, Input.OldPassword, Input.NewPassword);
if (!changePasswordResult.Succeeded)
{
foreach (var error in changePasswordResult.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
return Page();
}
await _signInManager.RefreshSignInAsync(user);
_logger.LogInformation("User changed their password successfully.");
StatusMessage = "Your password has been changed.";
return RedirectToPage();
}
}
}

View File

@ -1,33 +0,0 @@
@page
@model DeletePersonalDataModel
@{
ViewData["Title"] = "Delete Personal Data";
ViewData["ActivePage"] = ManageNavPages.PersonalData;
}
<h3>@ViewData["Title"]</h3>
<div class="alert alert-warning" role="alert">
<p>
<strong>Deleting this data will permanently remove your account, and this cannot be recovered.</strong>
</p>
</div>
<div>
<form id="delete-user" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
@if (Model.RequirePassword)
{
<div class="form-floating mb-3">
<input asp-for="Input.Password" class="form-control" autocomplete="current-password" aria-required="true" placeholder="Please enter your password." />
<label asp-for="Input.Password" class="form-label"></label>
<span asp-validation-for="Input.Password" class="text-danger"></span>
</div>
}
<button class="w-100 btn btn-lg btn-danger" type="submit">Delete data and close my account</button>
</form>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -1,104 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
{
public class DeletePersonalDataModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly SignInManager<UserModel> _signInManager;
private readonly ILogger<DeletePersonalDataModel> _logger;
public DeletePersonalDataModel(
UserManager<UserModel> userManager,
SignInManager<UserModel> signInManager,
ILogger<DeletePersonalDataModel> logger)
{
_userManager = userManager;
_signInManager = signInManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[DataType(DataType.Password)]
public string Password { get; set; }
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public bool RequirePassword { get; set; }
public async Task<IActionResult> OnGet()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
RequirePassword = await _userManager.HasPasswordAsync(user);
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
RequirePassword = await _userManager.HasPasswordAsync(user);
if (RequirePassword)
{
if (!await _userManager.CheckPasswordAsync(user, Input.Password))
{
ModelState.AddModelError(string.Empty, "Incorrect password.");
return Page();
}
}
var result = await _userManager.DeleteAsync(user);
var userId = await _userManager.GetUserIdAsync(user);
if (!result.Succeeded)
{
throw new InvalidOperationException($"Unexpected error occurred deleting user.");
}
await _signInManager.SignOutAsync();
_logger.LogInformation("User with ID '{UserId}' deleted themselves.", userId);
return Redirect("~/");
}
}
}

View File

@ -1,25 +0,0 @@
@page
@model Disable2faModel
@{
ViewData["Title"] = "Disable two-factor authentication (2FA)";
ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
}
<partial name="_StatusMessage" for="StatusMessage" />
<h3>@ViewData["Title"]</h3>
<div class="alert alert-warning" role="alert">
<p>
<strong>This action only disables 2FA.</strong>
</p>
<p>
Disabling 2FA does not change the keys used in authenticator apps. If you wish to change the key
used in an authenticator app you should <a asp-page="./ResetAuthenticator">reset your authenticator keys.</a>
</p>
</div>
<div>
<form method="post">
<button class="btn btn-danger" type="submit">Disable 2FA</button>
</form>
</div>

View File

@ -1,70 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
{
public class Disable2faModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly ILogger<Disable2faModel> _logger;
public Disable2faModel(
UserManager<UserModel> userManager,
ILogger<Disable2faModel> logger)
{
_userManager = userManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
public async Task<IActionResult> OnGet()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
if (!await _userManager.GetTwoFactorEnabledAsync(user))
{
throw new InvalidOperationException($"Cannot disable 2FA for user as it's not currently enabled.");
}
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var disable2faResult = await _userManager.SetTwoFactorEnabledAsync(user, false);
if (!disable2faResult.Succeeded)
{
throw new InvalidOperationException($"Unexpected error occurred disabling 2FA.");
}
_logger.LogInformation("User with ID '{UserId}' has disabled 2fa.", _userManager.GetUserId(User));
StatusMessage = "2fa has been disabled. You can reenable 2fa when you setup an authenticator app";
return RedirectToPage("./TwoFactorAuthentication");
}
}
}

View File

@ -1,12 +0,0 @@
@page
@model DownloadPersonalDataModel
@{
ViewData["Title"] = "Download Your Data";
ViewData["ActivePage"] = ManageNavPages.PersonalData;
}
<h3>@ViewData["Title"]</h3>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -1,68 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
{
public class DownloadPersonalDataModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly ILogger<DownloadPersonalDataModel> _logger;
public DownloadPersonalDataModel(
UserManager<UserModel> userManager,
ILogger<DownloadPersonalDataModel> logger)
{
_userManager = userManager;
_logger = logger;
}
public IActionResult OnGet()
{
return NotFound();
}
public async Task<IActionResult> OnPostAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
_logger.LogInformation("User with ID '{UserId}' asked for their personal data.", _userManager.GetUserId(User));
// Only include personal data for download
var personalData = new Dictionary<string, string>();
var personalDataProps = typeof(UserModel).GetProperties().Where(
prop => Attribute.IsDefined(prop, typeof(PersonalDataAttribute)));
foreach (var p in personalDataProps)
{
personalData.Add(p.Name, p.GetValue(user)?.ToString() ?? "null");
}
var logins = await _userManager.GetLoginsAsync(user);
foreach (var l in logins)
{
personalData.Add($"{l.LoginProvider} external login provider key", l.ProviderKey);
}
personalData.Add($"Authenticator Key", await _userManager.GetAuthenticatorKeyAsync(user));
Response.Headers.TryAdd("Content-Disposition", "attachment; filename=PersonalData.json");
return new FileContentResult(JsonSerializer.SerializeToUtf8Bytes(personalData), "application/json");
}
}
}

View File

@ -1,44 +0,0 @@
@page
@model EmailModel
@{
ViewData["Title"] = "Manage Email";
ViewData["ActivePage"] = ManageNavPages.Email;
}
<h3>@ViewData["Title"]</h3>
<partial name="_StatusMessage" for="StatusMessage" />
<div class="row">
<div class="col-md-6">
<form id="email-form" method="post">
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
@if (Model.IsEmailConfirmed)
{
<div class="form-floating mb-3 input-group">
<input asp-for="Email" class="form-control" placeholder="Please enter your email." disabled />
<div class="input-group-append">
<span class="h-100 input-group-text text-success font-weight-bold">✓</span>
</div>
<label asp-for="Email" class="form-label"></label>
</div>
}
else
{
<div class="form-floating mb-3">
<input asp-for="Email" class="form-control" placeholder="Please enter your email." disabled />
<label asp-for="Email" class="form-label"></label>
<button id="email-verification" type="submit" asp-page-handler="SendVerificationEmail" class="btn btn-link">Send verification email</button>
</div>
}
<div class="form-floating mb-3">
<input asp-for="Input.NewEmail" class="form-control" autocomplete="email" aria-required="true" placeholder="Please enter new email." />
<label asp-for="Input.NewEmail" class="form-label"></label>
<span asp-validation-for="Input.NewEmail" class="text-danger"></span>
</div>
<button id="change-email-button" type="submit" asp-page-handler="ChangeEmail" class="w-100 btn btn-lg btn-primary">Change email</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -1,172 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
{
public class EmailModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly SignInManager<UserModel> _signInManager;
private readonly IEmailSender _emailSender;
public EmailModel(
UserManager<UserModel> userManager,
SignInManager<UserModel> signInManager,
IEmailSender emailSender)
{
_userManager = userManager;
_signInManager = signInManager;
_emailSender = emailSender;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string Email { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public bool IsEmailConfirmed { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[EmailAddress]
[Display(Name = "New email")]
public string NewEmail { get; set; }
}
private async Task LoadAsync(UserModel user)
{
var email = await _userManager.GetEmailAsync(user);
Email = email;
Input = new InputModel
{
NewEmail = email,
};
IsEmailConfirmed = await _userManager.IsEmailConfirmedAsync(user);
}
public async Task<IActionResult> OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
await LoadAsync(user);
return Page();
}
public async Task<IActionResult> OnPostChangeEmailAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
if (!ModelState.IsValid)
{
await LoadAsync(user);
return Page();
}
var email = await _userManager.GetEmailAsync(user);
if (Input.NewEmail != email)
{
var userId = await _userManager.GetUserIdAsync(user);
var code = await _userManager.GenerateChangeEmailTokenAsync(user, Input.NewEmail);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = Url.Page(
"/Account/ConfirmEmailChange",
pageHandler: null,
values: new { area = "Identity", userId = userId, email = Input.NewEmail, code = code },
protocol: Request.Scheme);
await _emailSender.SendEmailAsync(
Input.NewEmail,
"Confirm your email",
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
StatusMessage = "Confirmation link to change email sent. Please check your email.";
return RedirectToPage();
}
StatusMessage = "Your email is unchanged.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostSendVerificationEmailAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
if (!ModelState.IsValid)
{
await LoadAsync(user);
return Page();
}
var userId = await _userManager.GetUserIdAsync(user);
var email = await _userManager.GetEmailAsync(user);
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = Url.Page(
"/Account/ConfirmEmail",
pageHandler: null,
values: new { area = "Identity", userId = userId, code = code },
protocol: Request.Scheme);
await _emailSender.SendEmailAsync(
email,
"Confirm your email",
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
StatusMessage = "Verification email sent. Please check your email.";
return RedirectToPage();
}
}
}

View File

@ -1,53 +0,0 @@
@page
@model EnableAuthenticatorModel
@{
ViewData["Title"] = "Configure authenticator app";
ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
}
<partial name="_StatusMessage" for="StatusMessage" />
<h3>@ViewData["Title"]</h3>
<div>
<p>To use an authenticator app go through the following steps:</p>
<ol class="list">
<li>
<p>
Download a two-factor authenticator app like Microsoft Authenticator for
<a href="https://go.microsoft.com/fwlink/?Linkid=825072">Android</a> and
<a href="https://go.microsoft.com/fwlink/?Linkid=825073">iOS</a> or
Google Authenticator for
<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2&amp;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" />
}

View File

@ -1,189 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.ComponentModel.DataAnnotations;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
{
public class EnableAuthenticatorModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly ILogger<EnableAuthenticatorModel> _logger;
private readonly UrlEncoder _urlEncoder;
private const string AuthenticatorUriFormat = "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6";
public EnableAuthenticatorModel(
UserManager<UserModel> userManager,
ILogger<EnableAuthenticatorModel> logger,
UrlEncoder urlEncoder)
{
_userManager = userManager;
_logger = logger;
_urlEncoder = urlEncoder;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string SharedKey { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string AuthenticatorUri { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string[] RecoveryCodes { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[StringLength(7, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Text)]
[Display(Name = "Verification Code")]
public string Code { get; set; }
}
public async Task<IActionResult> OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
await LoadSharedKeyAndQrCodeUriAsync(user);
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
if (!ModelState.IsValid)
{
await LoadSharedKeyAndQrCodeUriAsync(user);
return Page();
}
// Strip spaces and hyphens
var verificationCode = Input.Code.Replace(" ", string.Empty).Replace("-", string.Empty);
var is2faTokenValid = await _userManager.VerifyTwoFactorTokenAsync(
user, _userManager.Options.Tokens.AuthenticatorTokenProvider, verificationCode);
if (!is2faTokenValid)
{
ModelState.AddModelError("Input.Code", "Verification code is invalid.");
await LoadSharedKeyAndQrCodeUriAsync(user);
return Page();
}
await _userManager.SetTwoFactorEnabledAsync(user, true);
var userId = await _userManager.GetUserIdAsync(user);
_logger.LogInformation("User with ID '{UserId}' has enabled 2FA with an authenticator app.", userId);
StatusMessage = "Your authenticator app has been verified.";
if (await _userManager.CountRecoveryCodesAsync(user) == 0)
{
var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
RecoveryCodes = recoveryCodes.ToArray();
return RedirectToPage("./ShowRecoveryCodes");
}
else
{
return RedirectToPage("./TwoFactorAuthentication");
}
}
private async Task LoadSharedKeyAndQrCodeUriAsync(UserModel user)
{
// Load the authenticator key & QR code URI to display on the form
var unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(unformattedKey))
{
await _userManager.ResetAuthenticatorKeyAsync(user);
unformattedKey = await _userManager.GetAuthenticatorKeyAsync(user);
}
SharedKey = FormatKey(unformattedKey);
var email = await _userManager.GetEmailAsync(user);
AuthenticatorUri = GenerateQrCodeUri(email, unformattedKey);
}
private string FormatKey(string unformattedKey)
{
var result = new StringBuilder();
int currentPosition = 0;
while (currentPosition + 4 < unformattedKey.Length)
{
result.Append(unformattedKey.AsSpan(currentPosition, 4)).Append(' ');
currentPosition += 4;
}
if (currentPosition < unformattedKey.Length)
{
result.Append(unformattedKey.AsSpan(currentPosition));
}
return result.ToString().ToLowerInvariant();
}
private string GenerateQrCodeUri(string email, string unformattedKey)
{
return string.Format(
CultureInfo.InvariantCulture,
AuthenticatorUriFormat,
_urlEncoder.Encode("Microsoft.AspNetCore.Identity.UI"),
_urlEncoder.Encode(email),
unformattedKey);
}
}
}

View File

@ -1,53 +0,0 @@
@page
@model ExternalLoginsModel
@{
ViewData["Title"] = "Manage your external logins";
ViewData["ActivePage"] = ManageNavPages.ExternalLogins;
}
<partial name="_StatusMessage" for="StatusMessage" />
@if (Model.CurrentLogins?.Count > 0)
{
<h3>Registered Logins</h3>
<table class="table">
<tbody>
@foreach (var login in Model.CurrentLogins)
{
<tr>
<td id="@($"login-provider-{login.LoginProvider}")">@login.ProviderDisplayName</td>
<td>
@if (Model.ShowRemoveButton)
{
<form id="@($"remove-login-{login.LoginProvider}")" asp-page-handler="RemoveLogin" method="post">
<div>
<input asp-for="@login.LoginProvider" name="LoginProvider" type="hidden" />
<input asp-for="@login.ProviderKey" name="ProviderKey" type="hidden" />
<button type="submit" class="btn btn-primary" title="Remove this @login.ProviderDisplayName login from your account">Remove</button>
</div>
</form>
}
else
{
@: &nbsp;
}
</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>
}

View File

@ -1,142 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
{
public class ExternalLoginsModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly SignInManager<UserModel> _signInManager;
private readonly IUserStore<UserModel> _userStore;
public ExternalLoginsModel(
UserManager<UserModel> userManager,
SignInManager<UserModel> signInManager,
IUserStore<UserModel> userStore)
{
_userManager = userManager;
_signInManager = signInManager;
_userStore = userStore;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public IList<UserLoginInfo> CurrentLogins { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public IList<AuthenticationScheme> OtherLogins { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public bool ShowRemoveButton { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
public async Task<IActionResult> OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
CurrentLogins = await _userManager.GetLoginsAsync(user);
OtherLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync())
.Where(auth => CurrentLogins.All(ul => auth.Name != ul.LoginProvider))
.ToList();
string passwordHash = null;
if (_userStore is IUserPasswordStore<UserModel> userPasswordStore)
{
passwordHash = await userPasswordStore.GetPasswordHashAsync(user, HttpContext.RequestAborted);
}
ShowRemoveButton = passwordHash != null || CurrentLogins.Count > 1;
return Page();
}
public async Task<IActionResult> OnPostRemoveLoginAsync(string loginProvider, string providerKey)
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var result = await _userManager.RemoveLoginAsync(user, loginProvider, providerKey);
if (!result.Succeeded)
{
StatusMessage = "The external login was not removed.";
return RedirectToPage();
}
await _signInManager.RefreshSignInAsync(user);
StatusMessage = "The external login was removed.";
return RedirectToPage();
}
public async Task<IActionResult> OnPostLinkLoginAsync(string provider)
{
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
// Request a redirect to the external login provider to link a login for the current user
var redirectUrl = Url.Page("./ExternalLogins", pageHandler: "LinkLoginCallback");
var properties = _signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl, _userManager.GetUserId(User));
return new ChallengeResult(provider, properties);
}
public async Task<IActionResult> OnGetLinkLoginCallbackAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var userId = await _userManager.GetUserIdAsync(user);
var info = await _signInManager.GetExternalLoginInfoAsync(userId);
if (info == null)
{
throw new InvalidOperationException($"Unexpected error occurred loading external login info.");
}
var result = await _userManager.AddLoginAsync(user, info);
if (!result.Succeeded)
{
StatusMessage = "The external login was not added. External logins can only be associated with one account.";
return RedirectToPage();
}
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
StatusMessage = "The external login was added.";
return RedirectToPage();
}
}
}

View File

@ -1,27 +0,0 @@
@page
@model GenerateRecoveryCodesModel
@{
ViewData["Title"] = "Generate two-factor authentication (2FA) recovery codes";
ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
}
<partial name="_StatusMessage" for="StatusMessage" />
<h3>@ViewData["Title"]</h3>
<div class="alert alert-warning" role="alert">
<p>
<span class="glyphicon glyphicon-warning-sign"></span>
<strong>Put these codes in a safe place.</strong>
</p>
<p>
If you lose your device and don't have the recovery codes you will lose access to your account.
</p>
<p>
Generating new recovery codes does not change the keys used in authenticator apps. If you wish to change the key
used in an authenticator app you should <a asp-page="./ResetAuthenticator">reset your authenticator keys.</a>
</p>
</div>
<div>
<form method="post">
<button class="btn btn-danger" type="submit">Generate Recovery Codes</button>
</form>
</div>

View File

@ -1,83 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
{
public class GenerateRecoveryCodesModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly ILogger<GenerateRecoveryCodesModel> _logger;
public GenerateRecoveryCodesModel(
UserManager<UserModel> userManager,
ILogger<GenerateRecoveryCodesModel> logger)
{
_userManager = userManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string[] RecoveryCodes { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
public async Task<IActionResult> OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user);
if (!isTwoFactorEnabled)
{
throw new InvalidOperationException($"Cannot generate recovery codes for user because they do not have 2FA enabled.");
}
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var isTwoFactorEnabled = await _userManager.GetTwoFactorEnabledAsync(user);
var userId = await _userManager.GetUserIdAsync(user);
if (!isTwoFactorEnabled)
{
throw new InvalidOperationException($"Cannot generate recovery codes for user as they do not have 2FA enabled.");
}
var recoveryCodes = await _userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
RecoveryCodes = recoveryCodes.ToArray();
_logger.LogInformation("User with ID '{UserId}' has generated new 2FA recovery codes.", userId);
StatusMessage = "You have generated new recovery codes.";
return RedirectToPage("./ShowRecoveryCodes");
}
}
}

View File

@ -1,30 +0,0 @@
@page
@model IndexModel
@{
ViewData["Title"] = "Profile";
ViewData["ActivePage"] = ManageNavPages.Index;
}
<h3>@ViewData["Title"]</h3>
<partial name="_StatusMessage" for="StatusMessage" />
<div class="row">
<div class="col-md-6">
<form id="profile-form" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Username" class="form-control" placeholder="Please choose your username." disabled />
<label asp-for="Username" class="form-label"></label>
</div>
<div class="form-floating mb-3">
<input asp-for="Input.PhoneNumber" class="form-control" placeholder="Please enter your phone number."/>
<label asp-for="Input.PhoneNumber" class="form-label"></label>
<span asp-validation-for="Input.PhoneNumber" class="text-danger"></span>
</div>
<button id="update-profile-button" type="submit" class="w-100 btn btn-lg btn-primary">Save</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -1,119 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.ComponentModel.DataAnnotations;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
{
public class IndexModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly SignInManager<UserModel> _signInManager;
public IndexModel(
UserManager<UserModel> userManager,
SignInManager<UserModel> signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string Username { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Phone]
[Display(Name = "Phone number")]
public string PhoneNumber { get; set; }
}
private async Task LoadAsync(UserModel user)
{
var userName = await _userManager.GetUserNameAsync(user);
var phoneNumber = await _userManager.GetPhoneNumberAsync(user);
Username = userName;
Input = new InputModel
{
PhoneNumber = phoneNumber
};
}
public async Task<IActionResult> OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
await LoadAsync(user);
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
if (!ModelState.IsValid)
{
await LoadAsync(user);
return Page();
}
var phoneNumber = await _userManager.GetPhoneNumberAsync(user);
if (Input.PhoneNumber != phoneNumber)
{
var setPhoneResult = await _userManager.SetPhoneNumberAsync(user, Input.PhoneNumber);
if (!setPhoneResult.Succeeded)
{
StatusMessage = "Unexpected error when trying to set phone number.";
return RedirectToPage();
}
}
await _signInManager.RefreshSignInAsync(user);
StatusMessage = "Your profile has been updated";
return RedirectToPage();
}
}
}

View File

@ -1,123 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static class ManageNavPages
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string Index => "Index";
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string Email => "Email";
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string ChangePassword => "ChangePassword";
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string DownloadPersonalData => "DownloadPersonalData";
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string DeletePersonalData => "DeletePersonalData";
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string ExternalLogins => "ExternalLogins";
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string PersonalData => "PersonalData";
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string TwoFactorAuthentication => "TwoFactorAuthentication";
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string IndexNavClass(ViewContext viewContext) => PageNavClass(viewContext, Index);
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string EmailNavClass(ViewContext viewContext) => PageNavClass(viewContext, Email);
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string ChangePasswordNavClass(ViewContext viewContext) => PageNavClass(viewContext, ChangePassword);
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string DownloadPersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, DownloadPersonalData);
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string DeletePersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, DeletePersonalData);
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string ExternalLoginsNavClass(ViewContext viewContext) => PageNavClass(viewContext, ExternalLogins);
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string PersonalDataNavClass(ViewContext viewContext) => PageNavClass(viewContext, PersonalData);
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string TwoFactorAuthenticationNavClass(ViewContext viewContext) => PageNavClass(viewContext, TwoFactorAuthentication);
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public static string PageNavClass(ViewContext viewContext, string page)
{
var activePage = viewContext.ViewData["ActivePage"] as string
?? System.IO.Path.GetFileNameWithoutExtension(viewContext.ActionDescriptor.DisplayName);
return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? "active" : null;
}
}
}

View File

@ -1,27 +0,0 @@
@page
@model PersonalDataModel
@{
ViewData["Title"] = "Personal Data";
ViewData["ActivePage"] = ManageNavPages.PersonalData;
}
<h3>@ViewData["Title"]</h3>
<div class="row">
<div class="col-md-6">
<p>Your account contains personal data that you have given us. This page allows you to download or delete that data.</p>
<p>
<strong>Deleting this data will permanently remove your account, and this cannot be recovered.</strong>
</p>
<form id="download-data" asp-page="DownloadPersonalData" method="post">
<button class="btn btn-primary" type="submit">Download</button>
</form>
<p>
<a id="delete" asp-page="DeletePersonalData" class="btn btn-danger">Delete</a>
</p>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -1,37 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
{
public class PersonalDataModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly ILogger<PersonalDataModel> _logger;
public PersonalDataModel(
UserManager<UserModel> userManager,
ILogger<PersonalDataModel> logger)
{
_userManager = userManager;
_logger = logger;
}
public async Task<IActionResult> OnGet()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
return Page();
}
}
}

View File

@ -1,24 +0,0 @@
@page
@model ResetAuthenticatorModel
@{
ViewData["Title"] = "Reset authenticator key";
ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
}
<partial name="_StatusMessage" for="StatusMessage" />
<h3>@ViewData["Title"]</h3>
<div class="alert alert-warning" role="alert">
<p>
<span class="glyphicon glyphicon-warning-sign"></span>
<strong>If you reset your authenticator key your authenticator app will not work until you reconfigure it.</strong>
</p>
<p>
This process disables 2FA until you verify your authenticator app.
If you do not complete your authenticator app configuration you may lose access to your account.
</p>
</div>
<div>
<form id="reset-authenticator-form" method="post">
<button id="reset-authenticator-button" class="btn btn-danger" type="submit">Reset authenticator key</button>
</form>
</div>

View File

@ -1,68 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
{
public class ResetAuthenticatorModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly SignInManager<UserModel> _signInManager;
private readonly ILogger<ResetAuthenticatorModel> _logger;
public ResetAuthenticatorModel(
UserManager<UserModel> userManager,
SignInManager<UserModel> signInManager,
ILogger<ResetAuthenticatorModel> logger)
{
_userManager = userManager;
_signInManager = signInManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
public async Task<IActionResult> OnGet()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
await _userManager.SetTwoFactorEnabledAsync(user, false);
await _userManager.ResetAuthenticatorKeyAsync(user);
var userId = await _userManager.GetUserIdAsync(user);
_logger.LogInformation("User with ID '{UserId}' has reset their authentication app key.", user.Id);
await _signInManager.RefreshSignInAsync(user);
StatusMessage = "Your authenticator app key has been reset, you will need to configure your authenticator app using the new key.";
return RedirectToPage("./EnableAuthenticator");
}
}
}

View File

@ -1,35 +0,0 @@
@page
@model SetPasswordModel
@{
ViewData["Title"] = "Set password";
ViewData["ActivePage"] = ManageNavPages.ChangePassword;
}
<h3>Set your password</h3>
<partial name="_StatusMessage" for="StatusMessage" />
<p class="text-info">
You do not have a local username/password for this site. Add a local
account so you can log in without an external login.
</p>
<div class="row">
<div class="col-md-6">
<form id="set-password-form" method="post">
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.NewPassword" class="form-control" autocomplete="new-password" placeholder="Please enter your new password."/>
<label asp-for="Input.NewPassword" class="form-label"></label>
<span asp-validation-for="Input.NewPassword" class="text-danger"></span>
</div>
<div class="form-floating mb-3">
<input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" placeholder="Please confirm your new password."/>
<label asp-for="Input.ConfirmPassword" class="form-label"></label>
<span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Set password</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -1,115 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
{
public class SetPasswordModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly SignInManager<UserModel> _signInManager;
public SetPasswordModel(
UserManager<UserModel> userManager,
SignInManager<UserModel> signInManager)
{
_userManager = userManager;
_signInManager = signInManager;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "New password")]
public string NewPassword { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[DataType(DataType.Password)]
[Display(Name = "Confirm new password")]
[Compare("NewPassword", ErrorMessage = "The new password and confirmation password do not match.")]
public string ConfirmPassword { get; set; }
}
public async Task<IActionResult> OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var hasPassword = await _userManager.HasPasswordAsync(user);
if (hasPassword)
{
return RedirectToPage("./ChangePassword");
}
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
var addPasswordResult = await _userManager.AddPasswordAsync(user, Input.NewPassword);
if (!addPasswordResult.Succeeded)
{
foreach (var error in addPasswordResult.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
return Page();
}
await _signInManager.RefreshSignInAsync(user);
StatusMessage = "Your password has been set.";
return RedirectToPage();
}
}
}

View File

@ -1,25 +0,0 @@
@page
@model ShowRecoveryCodesModel
@{
ViewData["Title"] = "Recovery codes";
ViewData["ActivePage"] = "TwoFactorAuthentication";
}
<partial name="_StatusMessage" for="StatusMessage" />
<h3>@ViewData["Title"]</h3>
<div class="alert alert-warning" role="alert">
<p>
<strong>Put these codes in a safe place.</strong>
</p>
<p>
If you lose your device and don't have the recovery codes you will lose access to your account.
</p>
</div>
<div class="row">
<div class="col-md-12">
@for (var row = 0; row < Model.RecoveryCodes.Length; row += 2)
{
<code class="recovery-code">@Model.RecoveryCodes[row]</code><text>&nbsp;</text><code class="recovery-code">@Model.RecoveryCodes[row + 1]</code><br />
}
</div>
</div>

View File

@ -1,47 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class ShowRecoveryCodesModel : PageModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string[] RecoveryCodes { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public IActionResult OnGet()
{
if (RecoveryCodes == null || RecoveryCodes.Length == 0)
{
return RedirectToPage("./TwoFactorAuthentication");
}
return Page();
}
}
}

View File

@ -1,71 +0,0 @@
@page
@using Microsoft.AspNetCore.Http.Features
@model TwoFactorAuthenticationModel
@{
ViewData["Title"] = "Two-factor authentication (2FA)";
ViewData["ActivePage"] = ManageNavPages.TwoFactorAuthentication;
}
<partial name="_StatusMessage" for="StatusMessage" />
<h3>@ViewData["Title"]</h3>
@{
var consentFeature = HttpContext.Features.Get<ITrackingConsentFeature>();
@if (consentFeature?.CanTrack ?? true)
{
@if (Model.Is2faEnabled)
{
if (Model.RecoveryCodesLeft == 0)
{
<div class="alert alert-danger">
<strong>You have no recovery codes left.</strong>
<p>You must <a asp-page="./GenerateRecoveryCodes">generate a new set of recovery codes</a> before you can log in with a recovery code.</p>
</div>
}
else if (Model.RecoveryCodesLeft == 1)
{
<div class="alert alert-danger">
<strong>You have 1 recovery code left.</strong>
<p>You can <a asp-page="./GenerateRecoveryCodes">generate a new set of recovery codes</a>.</p>
</div>
}
else if (Model.RecoveryCodesLeft <= 3)
{
<div class="alert alert-warning">
<strong>You have @Model.RecoveryCodesLeft recovery codes left.</strong>
<p>You should <a asp-page="./GenerateRecoveryCodes">generate a new set of recovery codes</a>.</p>
</div>
}
if (Model.IsMachineRemembered)
{
<form method="post" style="display: inline-block">
<button type="submit" class="btn btn-primary">Forget this browser</button>
</form>
}
<a asp-page="./Disable2fa" class="btn btn-primary">Disable 2FA</a>
<a asp-page="./GenerateRecoveryCodes" class="btn btn-primary">Reset recovery codes</a>
}
<h4>Authenticator app</h4>
@if (!Model.HasAuthenticator)
{
<a id="enable-authenticator" asp-page="./EnableAuthenticator" class="btn btn-primary">Add authenticator app</a>
}
else
{
<a id="enable-authenticator" asp-page="./EnableAuthenticator" class="btn btn-primary">Set up authenticator app</a>
<a id="reset-authenticator" asp-page="./ResetAuthenticator" class="btn btn-primary">Reset authenticator app</a>
}
}
else
{
<div class="alert alert-danger">
<strong>Privacy and cookie policy have not been accepted.</strong>
<p>You must accept the policy before you can enable two factor authentication.</p>
</div>
}
}
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -1,90 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage
{
public class TwoFactorAuthenticationModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly SignInManager<UserModel> _signInManager;
private readonly ILogger<TwoFactorAuthenticationModel> _logger;
public TwoFactorAuthenticationModel(
UserManager<UserModel> userManager, SignInManager<UserModel> signInManager, ILogger<TwoFactorAuthenticationModel> logger)
{
_userManager = userManager;
_signInManager = signInManager;
_logger = logger;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public bool HasAuthenticator { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public int RecoveryCodesLeft { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public bool Is2faEnabled { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public bool IsMachineRemembered { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[TempData]
public string StatusMessage { get; set; }
public async Task<IActionResult> OnGetAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
HasAuthenticator = await _userManager.GetAuthenticatorKeyAsync(user) != null;
Is2faEnabled = await _userManager.GetTwoFactorEnabledAsync(user);
IsMachineRemembered = await _signInManager.IsTwoFactorClientRememberedAsync(user);
RecoveryCodesLeft = await _userManager.CountRecoveryCodesAsync(user);
return Page();
}
public async Task<IActionResult> OnPostAsync()
{
var user = await _userManager.GetUserAsync(User);
if (user == null)
{
return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
await _signInManager.ForgetTwoFactorClientAsync();
StatusMessage = "The current browser has been forgotten. When you login again from this browser you will be prompted for your 2fa code.";
return RedirectToPage();
}
}
}

View File

@ -1,29 +0,0 @@
@{
if (ViewData.TryGetValue("ParentLayout", out var parentLayout) && parentLayout != null)
{
Layout = parentLayout.ToString();
}
else
{
Layout = "/Areas/Identity/Pages/_Layout.cshtml";
}
}
<h1>Manage your account</h1>
<div>
<h2>Change your account settings</h2>
<hr />
<div class="row">
<div class="col-md-3">
<partial name="_ManageNav" />
</div>
<div class="col-md-9">
@RenderBody()
</div>
</div>
</div>
@section Scripts {
@RenderSection("Scripts", required: false)
}

View File

@ -1,15 +0,0 @@
@inject SignInManager<UserModel> SignInManager
@{
var hasExternalLogins = (await SignInManager.GetExternalAuthenticationSchemesAsync()).Any();
}
<ul class="nav nav-pills flex-column">
<li class="nav-item"><a class="nav-link @ManageNavPages.IndexNavClass(ViewContext)" id="profile" asp-page="./Index">Profile</a></li>
<li class="nav-item"><a class="nav-link @ManageNavPages.EmailNavClass(ViewContext)" id="email" asp-page="./Email">Email</a></li>
<li class="nav-item"><a class="nav-link @ManageNavPages.ChangePasswordNavClass(ViewContext)" id="change-password" asp-page="./ChangePassword">Password</a></li>
@if (hasExternalLogins)
{
<li id="external-logins" class="nav-item"><a id="external-login" class="nav-link @ManageNavPages.ExternalLoginsNavClass(ViewContext)" asp-page="./ExternalLogins">External logins</a></li>
}
<li class="nav-item"><a class="nav-link @ManageNavPages.TwoFactorAuthenticationNavClass(ViewContext)" id="two-factor" asp-page="./TwoFactorAuthentication">Two-factor authentication</a></li>
<li class="nav-item"><a class="nav-link @ManageNavPages.PersonalDataNavClass(ViewContext)" id="personal-data" asp-page="./PersonalData">Personal data</a></li>
</ul>

View File

@ -1,10 +0,0 @@
@model string
@if (!String.IsNullOrEmpty(Model))
{
var statusMessageClass = Model.StartsWith("Error") ? "danger" : "success";
<div class="alert alert-@statusMessageClass alert-dismissible" role="alert">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
@Model
</div>
}

View File

@ -1 +0,0 @@
@using PSTW_CentralSystem.Areas.Identity.Pages.Account.Manage

View File

@ -1,67 +0,0 @@
@page
@model RegisterModel
@{
ViewData["Title"] = "Register";
}
<h1>@ViewData["Title"]</h1>
<div class="row">
<div class="col-md-4">
<form id="registerForm" asp-route-returnUrl="@Model.ReturnUrl" method="post">
<h2>Create a new account.</h2>
<hr />
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
<label asp-for="Input.Email">Email</label>
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<div class="form-floating mb-3">
<input asp-for="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
<label asp-for="Input.Password">Password</label>
<span asp-validation-for="Input.Password" class="text-danger"></span>
</div>
<div class="form-floating mb-3">
<input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
<label asp-for="Input.ConfirmPassword">Confirm Password</label>
<span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
</div>
<button id="registerSubmit" type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
</form>
</div>
<div class="col-md-6 col-md-offset-2">
<section>
<h3>Use another service to register.</h3>
<hr />
@{
if ((Model.ExternalLogins?.Count ?? 0) == 0)
{
<div>
<p>
There are no external authentication services configured. See this <a href="https://go.microsoft.com/fwlink/?LinkID=532715">article
about setting up this ASP.NET application to support logging in via external services</a>.
</p>
</div>
}
else
{
<form id="external-account" asp-page="./ExternalLogin" asp-route-returnUrl="@Model.ReturnUrl" method="post" class="form-horizontal">
<div>
<p>
@foreach (var provider in Model.ExternalLogins!)
{
<button type="submit" class="btn btn-primary" name="provider" value="@provider.Name" title="Log in using your @provider.DisplayName account">@provider.DisplayName</button>
}
</p>
</div>
</form>
}
}
</section>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -1,182 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
{
[Authorize]
public class RegisterModel : PageModel
{
private readonly SignInManager<UserModel> _signInManager;
private readonly UserManager<UserModel> _userManager;
private readonly IUserStore<UserModel> _userStore;
private readonly IUserEmailStore<UserModel> _emailStore;
private readonly ILogger<RegisterModel> _logger;
private readonly IEmailSender _emailSender;
public RegisterModel(
UserManager<UserModel> userManager,
IUserStore<UserModel> userStore,
SignInManager<UserModel> signInManager,
ILogger<RegisterModel> logger,
IEmailSender emailSender)
{
_userManager = userManager;
_userStore = userStore;
_emailStore = GetEmailStore();
_signInManager = signInManager;
_logger = logger;
_emailSender = emailSender;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string ReturnUrl { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public IList<AuthenticationScheme> ExternalLogins { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[EmailAddress]
[Display(Name = "Email")]
public string Email { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)]
[DataType(DataType.Password)]
[Display(Name = "Password")]
public string Password { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[DataType(DataType.Password)]
[Display(Name = "Confirm password")]
[Compare("Password", ErrorMessage = "The password and confirmation password do not match.")]
public string ConfirmPassword { get; set; }
}
public async Task OnGetAsync(string returnUrl = null)
{
ReturnUrl = returnUrl;
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
}
public async Task<IActionResult> OnPostAsync(string returnUrl = null)
{
returnUrl ??= Url.Content("~/");
ExternalLogins = (await _signInManager.GetExternalAuthenticationSchemesAsync()).ToList();
if (ModelState.IsValid)
{
var user = CreateUser();
await _userStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
await _emailStore.SetEmailAsync(user, Input.Email, CancellationToken.None);
var result = await _userManager.CreateAsync(user, Input.Password);
if (result.Succeeded)
{
_logger.LogInformation("User created a new account with password.");
var userId = await _userManager.GetUserIdAsync(user);
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = Url.Page(
"/Account/ConfirmEmail",
pageHandler: null,
values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl },
protocol: Request.Scheme);
await _emailSender.SendEmailAsync(Input.Email, "Confirm your email",
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
if (_userManager.Options.SignIn.RequireConfirmedAccount)
{
return RedirectToPage("RegisterConfirmation", new { email = Input.Email, returnUrl = returnUrl });
}
else
{
await _signInManager.SignInAsync(user, isPersistent: false);
return LocalRedirect(returnUrl);
}
}
foreach (var error in result.Errors)
{
ModelState.AddModelError(string.Empty, error.Description);
}
}
// If we got this far, something failed, redisplay form
return Page();
}
private UserModel CreateUser()
{
try
{
return Activator.CreateInstance<UserModel>();
}
catch
{
throw new InvalidOperationException($"Can't create an instance of '{nameof(UserModel)}'. " +
$"Ensure that '{nameof(UserModel)}' is not an abstract class and has a parameterless constructor, or alternatively " +
$"override the register page in /Areas/Identity/Pages/Account/Register.cshtml");
}
}
private IUserEmailStore<UserModel> GetEmailStore()
{
if (!_userManager.SupportsUserEmail)
{
throw new NotSupportedException("The default UI requires a user store with email support.");
}
return (IUserEmailStore<UserModel>)_userStore;
}
}
}

View File

@ -1,23 +0,0 @@
@page
@model RegisterConfirmationModel
@{
ViewData["Title"] = "Register confirmation";
}
<h1>@ViewData["Title"]</h1>
@{
if (@Model.DisplayConfirmAccountLink)
{
<p>
This app does not currently have a real email sender registered, see <a href="https://aka.ms/aspaccountconf">these docs</a> for how to configure a real email sender.
Normally this would be emailed: <a id="confirm-link" href="@Model.EmailConfirmationUrl">Click here to confirm your account</a>
</p>
}
else
{
<p>
Please check your email to confirm your account.
</p>
}
}

View File

@ -1,80 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
{
[AllowAnonymous]
public class RegisterConfirmationModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly IEmailSender _sender;
public RegisterConfirmationModel(UserManager<UserModel> userManager, IEmailSender sender)
{
_userManager = userManager;
_sender = sender;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string Email { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public bool DisplayConfirmAccountLink { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public string EmailConfirmationUrl { get; set; }
public async Task<IActionResult> OnGetAsync(string email, string returnUrl = null)
{
if (email == null)
{
return RedirectToPage("/Index");
}
returnUrl = returnUrl ?? Url.Content("~/");
var user = await _userManager.FindByEmailAsync(email);
if (user == null)
{
return NotFound($"Unable to load user with email '{email}'.");
}
Email = email;
// Once you add a real email sender, you should remove this code that lets you confirm the account
DisplayConfirmAccountLink = true;
if (DisplayConfirmAccountLink)
{
var userId = await _userManager.GetUserIdAsync(user);
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
EmailConfirmationUrl = Url.Page(
"/Account/ConfirmEmail",
pageHandler: null,
values: new { area = "Identity", userId = userId, code = code, returnUrl = returnUrl },
protocol: Request.Scheme);
}
return Page();
}
}
}

View File

@ -1,26 +0,0 @@
@page
@model ResendEmailConfirmationModel
@{
ViewData["Title"] = "Resend email confirmation";
}
<h1>@ViewData["Title"]</h1>
<h2>Enter your email.</h2>
<hr />
<div class="row">
<div class="col-md-4">
<form method="post">
<div asp-validation-summary="All" class="text-danger" role="alert"></div>
<div class="form-floating mb-3">
<input asp-for="Input.Email" class="form-control" aria-required="true" placeholder="name@example.com" />
<label asp-for="Input.Email" class="form-label"></label>
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Resend</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

View File

@ -1,89 +0,0 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#nullable disable
using System;
using System.ComponentModel.DataAnnotations;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.WebUtilities;
using PSTW_CentralSystem.Models;
namespace PSTW_CentralSystem.Areas.Identity.Pages.Account
{
[AllowAnonymous]
public class ResendEmailConfirmationModel : PageModel
{
private readonly UserManager<UserModel> _userManager;
private readonly IEmailSender _emailSender;
public ResendEmailConfirmationModel(UserManager<UserModel> userManager, IEmailSender emailSender)
{
_userManager = userManager;
_emailSender = emailSender;
}
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[BindProperty]
public InputModel Input { get; set; }
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
public class InputModel
{
/// <summary>
/// This API supports the ASP.NET Core Identity default UI infrastructure and is not intended to be used
/// directly from your code. This API may change or be removed in future releases.
/// </summary>
[Required]
[EmailAddress]
public string Email { get; set; }
}
public void OnGet()
{
}
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid)
{
return Page();
}
var user = await _userManager.FindByEmailAsync(Input.Email);
if (user == null)
{
ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email.");
return Page();
}
var userId = await _userManager.GetUserIdAsync(user);
var code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code));
var callbackUrl = Url.Page(
"/Account/ConfirmEmail",
pageHandler: null,
values: new { userId = userId, code = code },
protocol: Request.Scheme);
await _emailSender.SendEmailAsync(
Input.Email,
"Confirm your email",
$"Please confirm your account by <a href='{HtmlEncoder.Default.Encode(callbackUrl)}'>clicking here</a>.");
ModelState.AddModelError(string.Empty, "Verification email sent. Please check your email.");
return Page();
}
}
}

View File

@ -1,37 +0,0 @@
@page
@model ResetPasswordModel
@{
ViewData["Title"] = "Reset password";
}
<h1>@ViewData["Title"]</h1>
<h2>Reset your password.</h2>
<hr />
<div class="row">
<div class="col-md-4">
<form method="post">
<div asp-validation-summary="ModelOnly" class="text-danger" role="alert"></div>
<input asp-for="Input.Code" type="hidden" />
<div class="form-floating mb-3">
<input asp-for="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
<label asp-for="Input.Email" class="form-label"></label>
<span asp-validation-for="Input.Email" class="text-danger"></span>
</div>
<div class="form-floating mb-3">
<input asp-for="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please enter your password." />
<label asp-for="Input.Password" class="form-label"></label>
<span asp-validation-for="Input.Password" class="text-danger"></span>
</div>
<div class="form-floating mb-3">
<input asp-for="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="Please confirm your password." />
<label asp-for="Input.ConfirmPassword" class="form-label"></label>
<span asp-validation-for="Input.ConfirmPassword" class="text-danger"></span>
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Reset</button>
</form>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}

Some files were not shown because too many files have changed in this diff Show More