476 lines
22 KiB
Plaintext
476 lines
22 KiB
Plaintext
@{
|
||
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>
|