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.
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.
Page Structure
Five logical areas inside <main>, plus five modal overlays.
All stats are derived client-side from the loaded members array — no extra API call. Unique positions are counted with a Set.
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 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.
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.
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.
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.
| Control | Variable | Param sent | Behaviour |
|---|---|---|---|
| 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.
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.
| Button | Class | Hover colour | Action |
|---|---|---|---|
| View | .view | Blue — var(--accent) | Opens #viewModal with the member's full details and grouped privileges. |
| Loans | .loans | Teal — var(--teal) | Opens #loansModal and fetches loans for this member from team_api?action=loans. |
| Edit | .edit | Gold — #d97706 | Opens #formModal pre-filled. Role privilege preview is loaded if a role is set. |
| Toggle | .tog | Sage — var(--sage) | Inline status flip. Sends PUT with only { team_status: "active"|"inactive" }. No modal — immediate. |
| Delete | .del | Rust — 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.
// 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.
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.
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 ID | Type | Required | Description |
|---|---|---|---|
| fName | text | Required | Full name. Maps to team_fullname. Used to generate avatar initials and colour. |
| fEmail | Required | Login email. Maps to team_email. | |
| fPhone | tel | Optional | Phone number. Maps to team_phone_number. |
| fRoleId | select | Optional | Populated from allRoles (active roles only). Selecting a role triggers onRoleChange() which fetches and displays a privilege preview panel. Maps to role_id. |
| fPos | text | Optional | Job title (e.g. "Branch Manager"). Maps to team_position. Used in the Positions unique-count stat. |
| fBranch | select | Optional | Populated from allBranches. Pre-selected via populateBranchSelect(m.branch_fk) on edit. Maps to branch_fk. Sent as null if blank. |
| fAddr | textarea | Optional | Street address. Maps to team_address. |
| fPwd | password | Add only | Min 8 characters enforced server-side. On edit, omitting the field leaves the existing password unchanged. Sent as team_password_hash. |
| fStatus | toggle | Default Active | Custom CSS toggle button. State stored in isOn. Toggled by toggleStatus(). Sends team_status: "active"|"inactive". |
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.
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.
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.
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
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.
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}.
team_fk).Database Schema
Columns inferred from field mappings, API response properties, and rendered data.
| Column | Type | Notes |
|---|---|---|
| team_id | int PK | Auto-increment primary key. Shown as #ID in table. |
| team_fullname | varchar | Full name. Drives avatar initials, colour, and display everywhere. |
| team_email | varchar | Login credential. Should be unique per company. Shown beneath name in member cell. |
| team_phone_number | varchar | Optional. Shown in the phone column and view modal. |
| team_position | varchar | Job title. Rendered as a blue chip. Used in the Positions unique-count stat. |
| team_address | varchar/text | Optional street address. Shown only in view modal (full-width row). |
| team_status | enum | active | inactive. Drives status pill colour, stats count, and filter. |
| team_password_hash | varchar | Hashed password. Sent as plain text from the form; backend is responsible for hashing. |
| team_last_login | datetime | Formatted via fDT() as "12 Jan 2025, 09:45". Shown as "Never" if null. |
| team_created_at | datetime | Creation timestamp. Shown in view modal as "Member Since". |
| role_id | int FK | Nullable FK to roles table. Resolved client-side via resolveRole(id). |
| branch_fk | int FK | Nullable FK to branches table. Resolved via resolveBranch(id). Used in loans modal banner and mobile cards. |
| 2fa_status | varchar | Display only. Shown in view modal. Defaults to "Disabled". |
API Payload
{
"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.
{ "team_status": "inactive" }
BSuccess Response
{
"success": true,
"data": { "team_id": 7 } // on create; null on update/delete
}
JavaScript State Variables
| Variable | Type | Description |
|---|---|---|
| members | array | Full list of member objects from the most recent load(). All renders and modal lookups read from this array. |
| filter | string | 'all' | 'active' | 'inactive'. Sent as ?status=. Changed by setFilter(). |
| branchFilter | string | Branch ID string or ''. Sent as &branch_id=. Changed by setBranchFilter(). |
| search | string | Current search query. Sent as &search=. Updated by debSearch() with 350 ms debounce. |
| editId | number|null | null for Add mode. Set to team_id for Edit. Determines POST vs PUT in saveMember(). |
| delId | number|null | ID of the member queued for deletion. Set by openDel(). |
| isOn | boolean | Current state of the status toggle in the form. true = active. Toggled by toggleStatus(). |
| allBranches | array | Branches loaded from team_api?type=branches. Used to populate form dropdown and filter select, and to resolve branch_fk to a name via resolveBranch(). |
| allRoles | array | Active roles from roles_api?action=list. Resolved by resolveRole() to render role chips in the table. |
| rolesManifest | array | Full privilege key→label→group manifest from roles_api?action=manifest. Used by getGroupedPrivs() and renderPrivPills(). |
| rolePrivsCache | object | Keyed by role_id. Caches privileges[] arrays from roles_api?action=get calls. |
| currentLoansTeamId | number|null | The team_id of the member whose loans are currently shown in the Loans modal. |
| currentLoansMonth | string | Active month filter in the Loans modal ("YYYY-MM" or '' for all time). |
| allLoansForMember | array | All loans for the currently open member (unfiltered). Month filtering is applied client-side in renderLoansTable(). |
Functions
| Function | Trigger | Description |
|---|---|---|
| init() | Page load | Concurrently fires loadBranches() and loadRoles() via Promise.all, then calls load(). |
| load() | init + filters + CRUD | Fetches 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 button | Validates required fields, builds payload, POSTs or PUTs. Spinner on button. On success: toast, close modal, reload. |
| confirmDelete() | Delete confirm | DELETEs team_api?id={delId}. Spinner on button. On success: toast, close modal, reload. |
| toggleStatus_member(id) | Toggle button | Flips team_status for the given member via a minimal PUT. No modal — inline and immediate. |
| openLoans(teamId) | Loans button | Sets banner, defaults month to current, shows loading state, opens modal, fetches all loans, updates banner totals, calls renderLoansTable(currentMonth). |
| renderLoansTable(monthFilter) | Month change / openLoans | Filters allLoansForMember client-side, updates summary chips and count badge, renders loan table rows. No API call. |
| onLoansMonthChange(val) | Month input | Sets currentLoansMonth, removes "All Time" active class, calls renderLoansTable(val). |
| clearLoansMonth() | All Time button | Clears 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 select | Lazy-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 button | Sets editId = null, defaults status toggle to Active, calls clearForm(), opens #formModal. |
| openEdit(id) | Edit button | Sets editId, populates all form fields from members array, pre-selects role + branch, shows password hint, opens #formModal. |
| openView(id) | View button | Lazily loads role privileges if needed, builds full detail HTML with grouped privilege panel, wires Edit button, opens #viewModal. |
| openDel(id) | Delete button | Sets 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.
| Order | Check | Field | Condition |
|---|---|---|---|
| #1 | Name required | fName | Empty after trim. Highlights field and returns. |
| #2 | Email required | fEmail | Empty after trim. Highlights field and returns. |
| #3 | Password required on Add | fPwd | Only checked when editId === null. Highlights field and returns if empty. |
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.team_api should independently validate email uniqueness, password strength, and any other business rules. Any success: false response is shown via the toast notification.