PSTW_CentralizeSystem/Areas/IT/Views/ApprovalDashboard/RequestReview.cshtml
2025-11-10 10:26:57 +08:00

633 lines
26 KiB
Plaintext

@{
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>