474 lines
19 KiB
Plaintext
474 lines
19 KiB
Plaintext
@{
|
|
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">×</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>
|