/ Docs / Team Management
Page Docs manage-team
Overview Page Structure API Endpoints Stats Dashboard Filters & Search Member Listing Roles & Privileges Add / Edit Modal View Modal Staff Loans Modal Delete Modal DB Schema API Payload JS State Variables Functions Validation Rules
Page Documentation manage-team

Team Management

Complete reference for the team administration page — covering the stats dashboard, multi-filter listing, role/privilege system, five modal dialogs (Add/Edit, View, Staff Loans, Delete, Toggle), and all team_api payload contracts.

CRUD + Status Toggle Roles & Privileges Staff Loans Modal Debounced Search Branch Filter

What This Page Does

The Team Management page (manage-team) is the full administrative interface for the staff roster. It allows creating, viewing, editing, deleting, and toggling the active/inactive status of team members. It also provides a dedicated Staff Loans Modal that shows all loans booked by a specific member — filterable by month with live summary statistics.

Data loads on init from team_api alongside branches and roles. All five modals reuse a single CSS overlay system. The page generates a staffId string server-side in PHP from the logged-in session name.

1Load Branches + Roles
2Fetch Members
3Stats + Table rendered
4User CRUD / View Loans
5Reload on success

Page Structure

Five logical areas inside <main>, plus five modal overlays.

Stats Row
.stats-row · Four KPI cards: Total Members, Active, Inactive, Positions

All stats are derived client-side from the loaded members array — no extra API call. Unique positions are counted with a Set.

Filters Row
.filters · Status pills, Branch dropdown, Live search

Three filter mechanisms combine into a single server-side query: status filter (All / Active / Inactive), branch dropdown, and debounced text search. All three are passed as query params to team_api.

Desktop Table + Mobile Cards
Dual-view · Table hidden ≤768px, cards shown

Desktop table has 8 columns (ID, Member, Position, Role/Privileges, Phone, Status, Last Login, Actions). Mobile cards are a scrollable flex column with a rotating 3-colour left-border stripe. Both rendered simultaneously from the same members array.

Five Modal Dialogs
Add/Edit · View · Staff Loans · Delete · (Toggle is inline, no modal)

All share the .m-overlay enter/exit animation. Clicking outside any modal calls closeModal(id). The Staff Loans modal is the most complex — it fetches data asynchronously and supports month filtering with client-side re-rendering.

API Endpoints

Four PHP files are consumed. All return {"success": bool, "data": ...}. The team_api file serves multiple purposes via query params and action suffixes.

GETapi/team_api?status={filter}&search={q}&branch_id={id}List team members
POSTapi/team_apiCreate new member (JSON body)
PUTapi/team_api?id={team_id}Update member or status toggle
DELETEapi/team_api?id={team_id}Delete member permanently
GETapi/team_api?action=loans&team_id={id}Staff loans for Loans Modal (all time)
GETapi/team_api?type=branchesBranch list for filters + form dropdown
GETapi/roles_api?action=listActive roles list for form dropdown
GETapi/roles_api?action=manifestPrivilege key→label + group manifest
GETapi/roles_api?action=get&id={role_id}Privilege keys for a specific role (cached)
Roles API called three times at different stages list and manifest are fetched at init via Promise.all. get (privilege detail for a specific role) is called lazily on-demand when a role is selected in the form or when a member's profile is viewed — with an in-memory cache to avoid redundant calls.

Stats Dashboard

Four KPI cards are computed entirely client-side from the members array returned by the last load() call. The cards update every time filters change and data reloads.

Total Members
24
All roles
Active
19
Currently active
Inactive
5
Suspended
Positions
7
Unique roles
render() — stats computation JavaScript
const tot = members.length;
const act = members.filter(m => m.team_status === 'active').length;
const pos = new Set(members.map(m => m.team_position).filter(Boolean)).size;

// sInact = tot - act (no extra filter needed)

Filters & Search

Three independent filter controls work together — any change triggers a fresh load() call with all active filters applied as query parameters. There is no client-side filtering; the server always filters the result.

ControlVariableParam sentBehaviour
Status pills filter ?status=all|active|inactive setFilter(f, btn) updates filter, toggles .active class on the clicked pill, calls load().
Branch dropdown branchFilter &branch_id={id} (omitted if empty) setBranchFilter(val) — populated from allBranches via populateBranchFilterSelect() after branches load.
Search input search &search={q} (omitted if empty) debSearch(v) — 350 ms debounce. Server searches against name, email, and position fields.

Member Listing

AAvatar System

Every member is represented by a coloured circular avatar with 1–2 letter initials, generated deterministically from the member's full name. The same avatar appears consistently in the table row, mobile card, view modal, and loans modal banner.

Avatar colour + initials generation JavaScript
const avatarPalette = ['#2563eb','#1b3560','#5a7a62','#b85c38','#4a5568','#334155'];

function aColor(s) {
  let h = 0;
  for (let i = 0; i < s.length; i++)
    h = s.charCodeAt(i) + ((h << 5) - h);
  return avatarPalette[Math.abs(h) % avatarPalette.length];
}

function initials(n) {
  return (n || 'TM').split(' ').slice(0, 2)
    .map(w => w[0]?.toUpperCase() || '').join('');
}

BAction Buttons

Each table row and mobile card has five action buttons. Each has a distinct hover colour to visually signal its destructiveness level.

ButtonClassHover colourAction
View.viewBlue — var(--accent)Opens #viewModal with the member's full details and grouped privileges.
Loans.loansTeal — var(--teal)Opens #loansModal and fetches loans for this member from team_api?action=loans.
Edit.editGold — #d97706Opens #formModal pre-filled. Role privilege preview is loaded if a role is set.
Toggle.togSage — var(--sage)Inline status flip. Sends PUT with only { team_status: "active"|"inactive" }. No modal — immediate.
Delete.delRust — var(--rust)Opens #delModal with member name confirmation.

CMoney Formatters

Two money helpers are used across the loans modal — full format for table cells and compact format for summary chips and banners.

fMoney() + fMoneyK() JavaScript
// Full: "NGN 1,250,000"
const fMoney = n => 'NGN ' + Number(n || 0).toLocaleString('en-NG');

// Compact: "1.3M", "250K", "900"
const fMoneyK = n => {
  const v = Number(n || 0);
  return v >= 1e6 ? (v/1e6).toFixed(1) + 'M'
       : v >= 1e3 ? (v/1e3).toFixed(0) + 'K'
       : v.toFixed(0);
};

Roles & Privileges

The role system connects two APIs: roles_api?action=list for the dropdown options, and roles_api?action=manifest for the map of privilege keys to human-readable labels grouped by category. A third call — roles_api?action=get&id=X — fetches the specific privilege keys for a chosen role and is cached per role_id.

AIn-Form Preview

When a role is selected in the Add/Edit form, a purple collapsible panel appears beneath the dropdown showing up to 8 privilege pills from that role, plus a "+N more" count badge if there are more. This preview is rendered from rolePrivsCache[roleId] after lazy-fetching the role's full detail.

Privilege cache rolePrivsCache is keyed by role_id. Once fetched, the same role's privileges are never re-fetched for the lifetime of the page session — the cached result is used for both the form preview and the View modal's grouped display.

BIn-View Modal Display

The View modal shows privileges grouped by their manifest category (e.g. "Loans", "Reports", "Settings"). getGroupedPrivs(privKeys) cross-references the manifest to build a { groupName: [label1, label2] } object, which is then rendered as a purple section with labelled pill groups.

getGroupedPrivs() — manifest cross-reference JavaScript
function getGroupedPrivs(privKeys) {
  const labelMap = {};
  rolesManifest.forEach(g =>
    g.items.forEach(i => {
      labelMap[i.key] = { label: i.label, group: g.group };
    })
  );
  const groups = {};
  privKeys.forEach(k => {
    const info = labelMap[k];
    if (!info) return;
    if (!groups[info.group]) groups[info.group] = [];
    groups[info.group].push(info.label);
  });
  return groups;  // { "Loans": ["Book Loan", "Disburse"], ... }
}

Add / Edit Modal

The same #formModal handles both create (POST) and update (PUT). The modal title and editId distinguish the mode. When editing, the password field becomes optional — the pwHint note appears and the Required asterisk is hidden. The status toggle defaults to Active on Add, and mirrors the member's current status on Edit.

AForm Sections

The form is divided into four labelled sub-sections, each with an uppercase coloured label: Personal Information, Role & Privileges, Location, and Account.

Field IDTypeRequiredDescription
fNametextRequiredFull name. Maps to team_fullname. Used to generate avatar initials and colour.
fEmailemailRequiredLogin email. Maps to team_email.
fPhonetelOptionalPhone number. Maps to team_phone_number.
fRoleIdselectOptionalPopulated from allRoles (active roles only). Selecting a role triggers onRoleChange() which fetches and displays a privilege preview panel. Maps to role_id.
fPostextOptionalJob title (e.g. "Branch Manager"). Maps to team_position. Used in the Positions unique-count stat.
fBranchselectOptionalPopulated from allBranches. Pre-selected via populateBranchSelect(m.branch_fk) on edit. Maps to branch_fk. Sent as null if blank.
fAddrtextareaOptionalStreet address. Maps to team_address.
fPwdpasswordAdd onlyMin 8 characters enforced server-side. On edit, omitting the field leaves the existing password unchanged. Sent as team_password_hash.
fStatustoggleDefault ActiveCustom CSS toggle button. State stored in isOn. Toggled by toggleStatus(). Sends team_status: "active"|"inactive".
Role is sent as null, not empty string The payload uses role_id: roleId !== '' ? roleId : null. Similarly branch_fk is sent as null when unset. This allows the backend to correctly store NULL in the database rather than an empty string.

View Modal

Opens from the eye button on any row. Entirely rendered from the in-memory members array plus the lazily-loaded role privilege cache — no API call on open (unless the role's privileges haven't been cached yet). An "Edit Member" button in the footer immediately transitions to the edit form.

AGrid Sections

The content is structured as: a centred avatar header → a 2-column detail grid → a purple privileges panel (only shown if the member has a role assigned). The detail grid includes: Email, Phone, Branch, Role, Address (full width), 2FA Status, Last Login, Member Since.

BPrivilege Panel

If a role is assigned, a purple-header panel renders privileges grouped by category using getGroupedPrivs(). Each group shows its name as a small label followed by coloured pills for each privilege. If the role has no privileges, an italic "No privileges assigned" hint is shown instead.

2FA Status field The view modal surfaces m['2fa_status'] from the member object. This field is display-only — two-factor authentication management is handled elsewhere in the system.

Staff Loans Modal

The most complex modal in the page. Opens via the teal loans button. It fetches all loans for the selected staff member in a single API call, caches them in allLoansForMember, then performs all month filtering client-side — no further API calls when the month changes.

1Open modal
2Set member banner
3Fetch all loans (API)
4Cache in allLoansForMember
5renderLoansTable(month)

AMember Banner

The top section of the modal is a teal-tinted banner showing the member's avatar, name, position + branch, and two headline stats (Total Loans and Volume). The banner stats always reflect the all-time totals regardless of the active month filter.

BMonth Filter

A native <input type="month"> defaults to the current calendar month when the modal opens. An "All Time" button clears the filter. Changing either control calls renderLoansTable(monthFilter) — no API re-fetch.

renderLoansTable() — client-side month filter JavaScript
let loans = allLoansForMember;

if (monthFilter) {
  loans = loans.filter(l => {
    if (!l.loan_dated) return false;
    return l.loan_dated.substring(0, 7) === monthFilter; // "YYYY-MM"
  });
}

// Summary chips (always reflect filtered list)
const sumActive  = loans.filter(l =>
  ['Active', 'Disbursed', 'Approved'].includes(l.loan_status)
).length;
const sumPending = loans.filter(l =>
  ['Pending', 'Rework'].includes(l.loan_status)
).length;

CSummary Chips

lsTotal
Count of all loans in current filter window. Blue chip.
lsActive
Active + Disbursed + Approved. Green chip.
lsPending
Pending + Rework. Amber chip.
lsAmount
Sum of loan_amount in filter window, formatted as compact (e.g. "4.2M"). Teal chip.

DLoan Status Chips

Each loan row has a coloured status chip. The CSS class is derived directly from the loan_status value.

Active Disbursed Approved Pending Rework Closed Rejected
View Profile button The modal footer includes a "View Profile" button that closes the Loans modal and immediately opens the View modal for the same member using currentLoansTeamId. This enables quick pivot between loan history and member details without returning to the table.

Delete Modal

Compact confirmation dialog (max-width 360px). The member's name is shown in the warning to prevent accidental deletion. Confirming calls confirmDelete() which sends DELETE to team_api?id={delId}.

Permanent, no soft-delete Deletion is irreversible. Consider deactivating (status toggle) instead if data history should be preserved. The backend should cascade-handle any foreign key constraints (e.g. loans with this member's team_fk).

Database Schema

Columns inferred from field mappings, API response properties, and rendered data.

ColumnTypeNotes
team_idint PKAuto-increment primary key. Shown as #ID in table.
team_fullnamevarcharFull name. Drives avatar initials, colour, and display everywhere.
team_emailvarcharLogin credential. Should be unique per company. Shown beneath name in member cell.
team_phone_numbervarcharOptional. Shown in the phone column and view modal.
team_positionvarcharJob title. Rendered as a blue chip. Used in the Positions unique-count stat.
team_addressvarchar/textOptional street address. Shown only in view modal (full-width row).
team_statusenumactive | inactive. Drives status pill colour, stats count, and filter.
team_password_hashvarcharHashed password. Sent as plain text from the form; backend is responsible for hashing.
team_last_logindatetimeFormatted via fDT() as "12 Jan 2025, 09:45". Shown as "Never" if null.
team_created_atdatetimeCreation timestamp. Shown in view modal as "Member Since".
role_idint FKNullable FK to roles table. Resolved client-side via resolveRole(id).
branch_fkint FKNullable FK to branches table. Resolved via resolveBranch(id). Used in loans modal banner and mobile cards.
2fa_statusvarcharDisplay only. Shown in view modal. Defaults to "Disabled".

API Payload

POST / PUT body — team_api JSON
{
  "team_fullname":      "John Adeyemi",
  "team_email":         "john@company.com",
  "team_phone_number":  "+234 800 000 0000",
  "team_position":      "Branch Manager",
  "role_id":            3,             // null if none selected
  "branch_fk":          2,             // null if none selected
  "team_address":       "14 Victoria Island, Lagos",
  "team_status":        "active",
  "team_password_hash": "plaintext"    // omitted on edit if blank
}

AStatus-only Toggle Payload

When the toggle button is clicked inline (not via the form), a minimal PUT is sent containing only the new status. The backend should treat partial updates as PATCH-style — only updating supplied fields.

toggleStatus_member() — minimal PUT JSON
{ "team_status": "inactive" }

BSuccess Response

team_api — success response JSON
{
  "success": true,
  "data": { "team_id": 7 }   // on create; null on update/delete
}

JavaScript State Variables

VariableTypeDescription
membersarrayFull list of member objects from the most recent load(). All renders and modal lookups read from this array.
filterstring'all' | 'active' | 'inactive'. Sent as ?status=. Changed by setFilter().
branchFilterstringBranch ID string or ''. Sent as &branch_id=. Changed by setBranchFilter().
searchstringCurrent search query. Sent as &search=. Updated by debSearch() with 350 ms debounce.
editIdnumber|nullnull for Add mode. Set to team_id for Edit. Determines POST vs PUT in saveMember().
delIdnumber|nullID of the member queued for deletion. Set by openDel().
isOnbooleanCurrent state of the status toggle in the form. true = active. Toggled by toggleStatus().
allBranchesarrayBranches loaded from team_api?type=branches. Used to populate form dropdown and filter select, and to resolve branch_fk to a name via resolveBranch().
allRolesarrayActive roles from roles_api?action=list. Resolved by resolveRole() to render role chips in the table.
rolesManifestarrayFull privilege key→label→group manifest from roles_api?action=manifest. Used by getGroupedPrivs() and renderPrivPills().
rolePrivsCacheobjectKeyed by role_id. Caches privileges[] arrays from roles_api?action=get calls.
currentLoansTeamIdnumber|nullThe team_id of the member whose loans are currently shown in the Loans modal.
currentLoansMonthstringActive month filter in the Loans modal ("YYYY-MM" or '' for all time).
allLoansForMemberarrayAll loans for the currently open member (unfiltered). Month filtering is applied client-side in renderLoansTable().

Functions

FunctionTriggerDescription
init()Page loadConcurrently fires loadBranches() and loadRoles() via Promise.all, then calls load().
load()init + filters + CRUDFetches team_api with active filter/search/branch params. Stores in members, calls render(). Shows error banner on failure.
render()After load()Computes 4 stats, builds desktop table and mobile cards HTML simultaneously. Handles empty state.
saveMember()Save buttonValidates required fields, builds payload, POSTs or PUTs. Spinner on button. On success: toast, close modal, reload.
confirmDelete()Delete confirmDELETEs team_api?id={delId}. Spinner on button. On success: toast, close modal, reload.
toggleStatus_member(id)Toggle buttonFlips team_status for the given member via a minimal PUT. No modal — inline and immediate.
openLoans(teamId)Loans buttonSets banner, defaults month to current, shows loading state, opens modal, fetches all loans, updates banner totals, calls renderLoansTable(currentMonth).
renderLoansTable(monthFilter)Month change / openLoansFilters allLoansForMember client-side, updates summary chips and count badge, renders loan table rows. No API call.
onLoansMonthChange(val)Month inputSets currentLoansMonth, removes "All Time" active class, calls renderLoansTable(val).
clearLoansMonth()All Time buttonClears month input value and currentLoansMonth, adds active class to "All Time" button, calls renderLoansTable('').
loadRoles()init()Fetches list and manifest concurrently, stores in allRoles and rolesManifest, calls populateRoleSelect().
onRoleChange(roleId)Role selectLazy-fetches role privileges if not cached. Shows/hides the privilege preview panel. Renders up to 8 pills.
getGroupedPrivs(privKeys)openView()Cross-references rolesManifest to group privilege keys by category. Returns { group: [labels] }.
openAdd()Add Member buttonSets editId = null, defaults status toggle to Active, calls clearForm(), opens #formModal.
openEdit(id)Edit buttonSets editId, populates all form fields from members array, pre-selects role + branch, shows password hint, opens #formModal.
openView(id)View buttonLazily loads role privileges if needed, builds full detail HTML with grouped privilege panel, wires Edit button, opens #viewModal.
openDel(id)Delete buttonSets delId and #delName, opens #delModal.
loadBranches()init()Fetches team_api?type=branches, stores in allBranches, calls populateBranchSelect() and populateBranchFilterSelect().
resolveBranch(id)render() / openView()Looks up branch name from allBranches by ID. Returns '—' if not found.
resolveRole(id)render() / openView()Looks up role object from allRoles by ID. Returns null if not found.

Validation Rules

Client-side validation runs at the top of saveMember(). Failing fields are highlighted red for 1.5 s via hl(id) and focused. The function returns early on first failure — it does not accumulate all errors.

OrderCheckFieldCondition
#1Name requiredfNameEmpty after trim. Highlights field and returns.
#2Email requiredfEmailEmpty after trim. Highlights field and returns.
#3Password required on AddfPwdOnly checked when editId === null. Highlights field and returns if empty.
Password on edit is optional If fPwd is blank during an edit, the team_password_hash key is simply omitted from the payload. The backend should not overwrite the existing password if this key is absent.
Server-side validation team_api should independently validate email uniqueness, password strength, and any other business rules. Any success: false response is shown via the toast notification.