FIX_salesTracker8

This commit is contained in:
sanjeev.p 2026-02-27 14:40:34 +05:30
parent 0037133f8a
commit 3cdd73a7ee
4 changed files with 763 additions and 1 deletions

View File

@ -914,7 +914,7 @@ $routes->group('sales', function($routes) {
$routes->get('/', 'SalesController::index');
$routes->get('loadactivities', 'SalesController::loadactivities');
$routes->get('loadtargets', 'SalesController::loadtargets');
$routes->get('page/(:segment)', 'SalesController::noPage/$1');
@ -997,6 +997,13 @@ $routes->group('sales', function($routes) {
// Delete note
$routes->delete('notes/(:num)', 'SalesController::deleteNote/$1');
// ==================== TARGETS ROUTES ====================
$routes->get('targets', 'SalesController::getTargets');
$routes->get('targets/user/(:num)', 'SalesController::getTargetByUser/$1');
$routes->get('targets/fy/(:segment)','SalesController::getTargetByFY/$1');
$routes->post('targets', 'SalesController::createTarget');
$routes->put('targets/(:num)', 'SalesController::updateTarget/$1');
$routes->delete('targets/(:num)', 'SalesController::deleteTarget/$1');
//Dashboard
$routes->get('dashboard', 'SalesController::dashboard');

View File

@ -7,6 +7,7 @@ use App\Models\SalesActualLeadModel;
use App\Models\SalesContactPersonModel;
use App\Models\SalesActivityModel;
use App\Models\SalesLeadNoteModel;
use App\Models\SalesTargetModel;
use App\Models\UserModel;
use CodeIgniter\HTTP\ResponseInterface;
use CodeIgniter\API\ResponseTrait;
@ -20,6 +21,8 @@ class SalesController extends BaseController
protected $activityModel;
protected $noteModel;
protected $userModel;
protected $targetModel;
public function __construct()
{
@ -28,6 +31,7 @@ class SalesController extends BaseController
$this->activityModel = new SalesActivityModel();
$this->noteModel = new SalesLeadNoteModel();
$this->userModel = new UserModel();
$this->targetModel = new SalesTargetModel();
}
@ -760,6 +764,138 @@ class SalesController extends BaseController
}
}
// ==================== SALES TARGET APIs ====================
/**
* Get all sales targets
* GET /api/sales/targets
*/
public function getTargets()
{
try {
$targets = $this->targetModel->findAll();
return $this->respond([
'status' => 'success',
'data' => $targets
]);
} catch (\Exception $e) {
return $this->fail($e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Get sales target by user
* GET /api/sales/targets/user/{userId}
*/
public function getTargetByUser($userId)
{
try {
$targets = $this->targetModel->where('user_id', $userId)->findAll();
return $this->respond([
'status' => 'success',
'data' => $targets
]);
} catch (\Exception $e) {
return $this->fail($e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Get sales target by FY year
* GET /api/sales/targets/fy/{fyYear}
*/
public function getTargetByFY($fyYear)
{
try {
$targets = $this->targetModel->where('fy_year', $fyYear)->findAll();
return $this->respond([
'status' => 'success',
'data' => $targets
]);
} catch (\Exception $e) {
return $this->fail($e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Create sales target
* POST /api/sales/targets
*/
public function createTarget()
{
try {
$data = $this->request->getJSON(true);
$data['created_by'] = $this->getUserId();
$data['updated_by'] = $this->getUserId();
if (!$this->targetModel->insert($data)) {
return $this->fail($this->targetModel->errors(), ResponseInterface::HTTP_BAD_REQUEST);
}
$targetId = $this->targetModel->getInsertID();
$target = $this->targetModel->find((int)$targetId);
return $this->respondCreated([
'status' => 'success',
'message' => 'Sales target created successfully',
'data' => $target
]);
} catch (\Exception $e) {
return $this->fail($e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Update sales target
* PUT /api/sales/targets/{id}
*/
public function updateTarget($id)
{
try {
$target = $this->targetModel->find((int)$id);
if (!$target) {
return $this->failNotFound('Sales target not found');
}
$data = $this->request->getJSON(true);
$data['updated_by'] = $this->getUserId();
if (!$this->targetModel->update($id, $data)) {
return $this->fail($this->targetModel->errors(), ResponseInterface::HTTP_BAD_REQUEST);
}
$updatedTarget = $this->targetModel->find((int)$id);
return $this->respond([
'status' => 'success',
'message' => 'Sales target updated successfully',
'data' => $updatedTarget
]);
} catch (\Exception $e) {
return $this->fail($e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Delete sales target
* DELETE /api/sales/targets/{id}
*/
public function deleteTarget($id)
{
try {
$target = $this->targetModel->find((int)$id);
if (!$target) {
return $this->failNotFound('Sales target not found');
}
$this->targetModel->delete((int)$id);
return $this->respondDeleted([
'status' => 'success',
'message' => 'Sales target deleted successfully'
]);
} catch (\Exception $e) {
return $this->fail($e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
}
}
// ==================== HELPER METHODS ====================
/**

View File

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class SalesTargetModel extends Model
{
protected $table = 'sales_target';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'user_id',
'fy_year',
'target_amount',
'created_by',
'updated_by'
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $validationRules = [
'user_id' => 'required|integer',
'fy_year' => 'required|max_length[9]',
'target_amount' => 'required|decimal',
];
protected $validationMessages = [
'user_id' => ['required' => 'User is required'],
'fy_year' => ['required' => 'Financial year is required'],
'target_amount' => ['required' => 'Target amount is required', 'decimal' => 'Target amount must be a valid number'],
];
}

View File

@ -0,0 +1,581 @@
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }
/* ═══════════════════════════════════════════
LAYOUT Page Shell
═══════════════════════════════════════════ */
.main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.top-bar {
display: flex;
justify-content: space-between; /* pushes subtitle left, search right */
align-items: center; /* vertically centers both */
padding: 14px 28px;
background: white;
/* border-bottom: 1px solid #e0e0e0; */
}
/* ═══════════════════════════════════════════
SEARCH INPUT
═══════════════════════════════════════════ */
.search-input { padding: 9px 14px; border: 1px solid #e0e0e0; border-radius: 8px; font-size: 14px; width: 240px; outline: none; background: #f5f5f5; transition: border-color .2s;}
.search-input:focus { border-color: #ff6b35; }
/* ═══════════════════════════════════════════
CONTENT AREA
═══════════════════════════════════════════ */
.content-area { width: 100%; }
.content { padding: 10px; width: 100%; max-width: 100%;}
.subtitle { font-size: 13px; color: #999; margin-bottom: 20px; }
.view-container { display: none; }
.view-container.active { display: block; }
/* ═══════════════════════════════════════════
MAIN TABLE
═══════════════════════════════════════════ */
.tt-table-wrapper { border: 1px solid #e0e0e0; border-radius: 10px; overflow: hidden; width: 100%;}
.tt-main-table { width: 100%;border-collapse: collapse;table-layout: fixed;}
.tt-main-table thead { display: table; width: 100%; table-layout: fixed; }
.tt-main-table thead th { background: #f4f4f4; padding: 11px 14px; text-align: left;font-size: 12px;font-weight: 700;color: #777;text-transform: uppercase;letter-spacing: .4px;border-bottom: 1px solid #e0e0e0;}
.tt-main-table tbody { display: block; max-height: 530px; overflow-y: auto;}
.tt-main-table tbody tr { display: table; width: 100%; table-layout: fixed; }
.tt-main-table tbody td { padding: 12px 14px; font-size: 14px; border-bottom: 1px solid #f0f0f0; vertical-align: middle;}
.tt-main-table tbody tr:last-child td { border-bottom: none; }
.tt-main-table tbody tr:hover td { background: #fafafa; }
/* Empty row — must stay as table-row (not display:table override) */
.tt-main-table tbody tr.empty-row { display: table-row; width: 100%; }
.tt-main-table tbody tr.empty-row td { width: 100%; text-align: center; }
/* ═══════════════════════════════════════════
MEMBER AVATAR (used inside main table)
═══════════════════════════════════════════ */
.tt-person-cell { display: flex; align-items: center; gap: 1px;}
/* .tt-avatar { width: 38px; height: 38px; border-radius: 50%; background: #ff6b35; color: white; font-size: 14px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0;} */
/* ═══════════════════════════════════════════
EMPTY STATE
═══════════════════════════════════════════ */
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: #aaa; gap: 10px; }
.empty-icon { font-size: 36px; opacity: 0.5; line-height: 1;}
.empty-state p { margin: 0; font-size: 14px; font-weight: 500; color: #bbb;}
.empty-row td { padding: 0 !important; border: none !important; background: transparent !important;}
/* ═══════════════════════════════════════════
MODAL Overlay & Box
═══════════════════════════════════════════ */
.modal { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.5); z-index: 2000; align-items: center; justify-content: center;}
.modal.active { display: flex; }
.modal-box { background: white; border-radius: 14px; width: 92%; max-width: 640px; max-height: 90vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 8px 32px rgba(0, 0, 0, .18); animation: slideUp .2s ease;}
.modal-header { padding: 6px 25px; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; }
.modal-body { padding: 25px; flex: 1; overflow-y: auto;}
/* ═══════════════════════════════════════════
MODAL Form Fields
═══════════════════════════════════════════ */
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 15px;}
.form-group { margin-bottom: 20px; }
.form-label { display: block; margin-bottom: 8px; font-size: 14px; font-weight: 500; color: #333;}
.form-input,
.form-textarea,
.form-select {
width: 100%;
padding: 10px 15px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
font-family: inherit;
background: #f5f5f5; /* grey background */
color: #333;
transition: border-color .2s, background .2s;
}
.form-textarea { resize: vertical; min-height: 100px;}
.form-input:focus,
.form-textarea:focus,
.form-select:focus { outline: none; border-color: #ff6b35; background: #fff;}
/* ═══════════════════════════════════════════
MODAL Detail Table (targets list)
═══════════════════════════════════════════ */
.table-wrap { border: 1px solid #e0e0e0; border-radius: 10px; overflow: hidden; margin-top: 16px;}
.table-wrap table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.table-wrap thead { display: table; width: 100%; table-layout: fixed; }
.table-wrap thead th { background: #f8f8f8; padding: 12px 16px; text-align: left; font-size: 13px; font-weight: 600; color: #666; border-bottom: 1px solid #e0e0e0;}
.table-wrap tbody { display: block; max-height: 220px; overflow-y: auto; }
.table-wrap tbody tr { display: table; width: 100%; table-layout: fixed; }
.table-wrap tbody td { padding: 12px 16px; border-bottom: 1px solid #f0f0f0; font-size: 14px; color: #333; vertical-align: middle;}
.table-wrap tbody tr:last-child td { border-bottom: none; }
.table-wrap tbody tr:hover td { background: #fafafa; }
/* Empty row fix for modal table */
.table-wrap tbody tr.empty-row { display: table-row; }
.table-wrap tbody tr.empty-row td { padding: 0 !important; border: none !important; text-align: center;}
/* ═══════════════════════════════════════════
BUTTONS
═══════════════════════════════════════════ */
/* Primary action — View Target (main table) */
.btn-tt-view {
background: #ff6b35;
color: white;
border: none;
padding: 7px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background .2s;
}
.btn-tt-view:hover { background: #ff5722; }
/* Close button — modal header */
.close-btn {
background: none;
border: none;
font-size: 22px;
cursor: pointer;
color: #888;
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: background .2s;
}
.close-btn:hover { background: #f0f0f0; color: #333; }
/* Edit row — modal detail table */
.btn-edit {
background: #e8f4ff;
color: #1976d2;
border: none;
padding: 5px 12px;
border-radius: 5px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
margin-right: 6px;
transition: background .2s;
}
.btn-edit:hover { background: #bbdefb; }
/* Remove row — modal detail table */
.btn-remove {
background: #ffeaea;
color: #d32f2f;
border: none;
padding: 5px 12px;
border-radius: 5px;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: background .2s;
}
.btn-remove:hover { background: #ffcdd2; }
/* ═══════════════════════════════════════════
ANIMATIONS
═══════════════════════════════════════════ */
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* ═══════════════════════════════════════════
RESPONSIVE
═══════════════════════════════════════════ */
@media (max-width: 540px) {
.form-row { grid-template-columns: 1fr; }
.search-input { width: 100%; }
}
/* ═══════════════════════════════════════════
GREY BOX Input Section Container
═══════════════════════════════════════════ */
.grey-box {
background: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 10px;
padding: 16px 18px;
margin-bottom: 20px;
}
.grey-box .form-row {
display: grid;
grid-template-columns: 1fr 1fr auto; /* 2 fields + button */
gap: 15px;
align-items: flex-end; /* aligns button to bottom of fields */
}
.grey-box .form-group {
margin-bottom: 0; /* remove default gap inside grey box */
}
.grey-box .btn {
white-space: nowrap;
height: 40px; /* match input height */
width: 100%;
}
@media (max-width: 540px) {
.grey-box .form-row {
grid-template-columns: 1fr; /* stack vertically on mobile */
}
}
</style>
<div class="main-content">
<hr class="my-0">
<div class="top-bar">
<p class="subtitle" id="memberCount"></p>
<input type="text" class="search-input" id="searchInput" placeholder="Search member…" oninput="filterMembers(this.value)">
</div>
<!-- Content -->
<div class="content">
<div class="content-area view-container active" id="teamtargetView">
<div class="tt-table-wrapper">
<table class="tt-main-table">
<thead>
<tr>
<th>Person</th>
<th>Action</th>
</tr>
</thead>
<tbody id="ttMainTableBody"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal -->
<div class="modal" id="targetModal">
<div class="modal-box">
<div class="modal-header">
<span class="modal-title" id="modalTitle"></span>
<button class="close-btn" onclick="closeModal()" title="Close">×</button>
</div>
<div class="modal-body">
<input type="hidden" id="recordId">
<input type="hidden" id="currentUserId">
<div class="grey-box">
<div class="form-row">
<div class="form-group">
<label class="form-label">Financial Year</label>
<select class="form-select" id="fyYear"></select>
</div>
<div class="form-group">
<label class="form-label">Amount ()</label>
<input type="text" class="form-input" id="targetAmount" placeholder="e.g. 500000"
oninput="this.value=this.value.replace(/[^0-9]/g,'')">
</div>
<div class="form-group">
<button type="button" class="btn btn-primary waves-effect btn-sm w-100" id="btn-save" onclick="saveRecord()">Save Target</button>
</div>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Financial Year</th>
<th>Amount</th>
<th>Action</th>
</tr>
</thead>
<tbody id="detailTableBody">
<tr class="empty-row"><td colspan="3">No targets added yet.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
const users = <?= json_encode($users ?? []) ?>;
/* ──────────────────────────────────────────
DATA
────────────────────────────────────────── */
const teamMembers = users.map(user => ({
id: user.id,
name: user.first_name + ' ' + user.last_name
}));
const API = '<?= base_url('sales') ?>';
let activeUserId = null;
/* ──────────────────────────────────────────
RENDER MEMBER LIST
────────────────────────────────────────── */
function renderMemberList(filter = '') {
const tbody = document.getElementById('ttMainTableBody');
const countEl = document.getElementById('memberCount');
const term = filter.trim().toLowerCase();
const filtered = teamMembers.filter(m =>
m.name.toLowerCase().includes(term)
);
// countEl.textContent = `${filtered.length} member${filtered.length !== 1 ? 's' : ''}`;
countEl.textContent = ``;
tbody.innerHTML = '';
if (filtered.length === 0) {
tbody.innerHTML = `
<tr class="empty-row">
<td colspan="2">
<div class="empty-state">
<div class="empty-icon">👤</div>
<p>No Member found</p>
</div>
</td>
</tr>
`;
return;
}
filtered.forEach(m => {
// const initials = m.name.split(' ').map(w => w[0]).join('').substring(0, 2).toUpperCase();
const tr = document.createElement('tr');
tr.innerHTML = `
<td id="card_${m.id}">
<div class="tt-person-cell">
<div class="avatar-md">
<div class="avatar-title bg-soft-primary rounded-circle text-primary font-20 font-weight-semibold"
style="background-color: #f1f3fa; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center;">
${(m.name || '?').charAt(0).toUpperCase()}
</div>
</div>
<span>${m.name}</span>
</div>
</td>
<td>
<button class="btn btn-primary waves-effect waves-light" onclick="openModal(${m.id})">View Target</button>
</td>
`;
tbody.appendChild(tr);
});
}
function filterMembers(val) {
renderMemberList(val);
}
/* ──────────────────────────────────────────
FY OPTIONS
────────────────────────────────────────── */
function populateFY(selectedFY = null) {
const sel = document.getElementById('fyYear');
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth(); // 0-11
// Determine current FY start year
let currentFYStart;
if (month >= 3) { // April (3) or later
currentFYStart = year;
} else { // Jan, Feb, Mar
currentFYStart = year - 1;
}
sel.innerHTML = '';
for (let i = 0; i < 10; i++) {
const startYear = currentFYStart - i;
const label = `${startYear}-${startYear + 1}`;
const opt = document.createElement('option');
opt.value = label;
opt.textContent = label;
if (label === selectedFY) opt.selected = true;
sel.appendChild(opt);
}
}
/* ──────────────────────────────────────────
MODAL
────────────────────────────────────────── */
function openModal(userId) {
activeUserId = userId;
const member = teamMembers.find(m => Number(m.id) === Number(userId));
document.getElementById('modalTitle').textContent = member ? member.name : '';
document.getElementById('currentUserId').value = userId;
document.getElementById('recordId').value = '';
document.getElementById('targetAmount').value = '';
populateFY();
renderDetailTable(userId);
document.getElementById('targetModal').classList.add('active');
}
function closeModal() {
document.getElementById('targetModal').classList.remove('active');
activeUserId = null;
}
document.getElementById('targetModal').addEventListener('click', function(e) {
if (e.target === this) closeModal();
});
/* ──────────────────────────────────────────
RENDER DETAIL TABLE
────────────────────────────────────────── */
async function renderDetailTable(userId) {
const tbody = document.getElementById('detailTableBody');
tbody.innerHTML = '<tr><td colspan="3">Loading...</td></tr>';
try {
const res = await fetch(`${API}/targets/user/${userId}`);
const json = await res.json();
const records = json.data || [];
if (records.length === 0) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="3">No targets added yet.</td></tr>';
return;
}
records.sort((a, b) => b.id - a.id);
tbody.innerHTML = records.map(r => `
<tr id="row_${r.id}">
<td>${r.fy_year}</td>
<td> ${formatNum(r.target_amount)}</td>
<td>
<button class="btn-edit" onclick="editRecord(${r.id}, '${r.fy_year}', ${r.target_amount})">Edit</button>
<button class="btn-remove" onclick="removeRecord(${r.id})">Remove</button>
</td>
</tr>
`).join('');
} catch (err) {
tbody.innerHTML = '<tr><td colspan="3">Failed to load targets.</td></tr>';
console.error(err);
}
}
/* ──────────────────────────────────────────
SAVE (INSERT / UPDATE)
────────────────────────────────────────── */
async function saveRecord() {
const userId = activeUserId;
const recordId = document.getElementById('recordId').value.trim();
const fyYear = document.getElementById('fyYear').value;
const amtRaw = document.getElementById('targetAmount').value.trim();
// Validation
if (!fyYear) { toastr.error('Please select a Financial Year.'); return; }
if (!amtRaw) { toastr.error('Please enter an amount.'); return; }
const amount = parseInt(amtRaw, 10);
if (isNaN(amount) || amount <= 0) { toastr.error('Enter a valid positive amount.'); return; }
const existingRows = document.querySelectorAll('#detailTableBody tr[id^="row_"]');
for (const row of existingRows) {
const rowId = row.id.replace('row_', '');
const rowFY = row.cells[0].textContent.trim();
// Skip the row being edited
if (recordId && rowId === recordId) continue;
if (rowFY === fyYear) {
toastr.error(`A target for ${fyYear} already exists.`);
return;
}
}
const saveBtn = document.getElementById('btn-save');
const originalBtnText = saveBtn.innerText;
saveBtn.disabled = true;
saveBtn.innerText = recordId ? 'Updating...' : 'Saving...';
try {
const payload = {
user_id: userId,
fy_year: fyYear,
target_amount: amount
};
const isUpdate = !!recordId;
const url = isUpdate ? `${API}/targets/${recordId}` : `${API}/targets`;
const method = isUpdate ? 'PUT' : 'POST';
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await res.json();
if (res.ok) {
toastr.success(isUpdate ? 'Target updated!' : 'Target saved!');
// Reset form
document.getElementById('recordId').value = '';
document.getElementById('targetAmount').value = '';
populateFY();
renderDetailTable(userId);
} else {
toastr.error(result.message || 'Failed to save target.');
}
} catch (err) {
console.error(err);
toastr.error('A network error occurred.');
} finally {
saveBtn.disabled = false;
saveBtn.innerText = originalBtnText;
}
}
/* ──────────────────────────────────────────
EDIT populate form from table row data
────────────────────────────────────────── */
function editRecord(id, fyYear, amount) {
document.getElementById('recordId').value = id;
document.getElementById('targetAmount').value = amount;
populateFY(fyYear);
}
/* ──────────────────────────────────────────
REMOVE
────────────────────────────────────────── */
async function removeRecord(id) {
if (!confirm('Are you sure you want to remove this target?')) return;
try {
const res = await fetch(`${API}/targets/${id}`, { method: 'DELETE' });
const result = await res.json();
if (res.ok) {
toastr.success('Target removed.');
renderDetailTable(activeUserId);
} else {
toastr.error(result.message || 'Failed to remove target.');
}
} catch (err) {
console.error(err);
toastr.error('A network error occurred.');
}
}
/* ──────────────────────────────────────────
HELPERS
────────────────────────────────────────── */
function formatNum(n) {
return Number(n).toLocaleString('en-IN');
}
/* ──────────────────────────────────────────
INIT
────────────────────────────────────────── */
renderMemberList();
</script>