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

476 lines
22 KiB
Plaintext
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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