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

558 lines
25 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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"] = "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>
}