FEAT_ENROLLMENT&HR_DASHBOARD

This commit is contained in:
velz 2026-06-05 11:32:52 +05:30
parent c22eebc96b
commit ed9124d022
7 changed files with 821 additions and 0 deletions

View File

@ -471,6 +471,15 @@ $routes->group("/util", ["filter" => "authMVC"], function ($routes) {
$routes->get('debug', 'ClaimsCollectionV2DashboardController::debug');
$routes->get('debug/(:num)', 'ClaimsCollectionV2DashboardController::debug/$1');
});
$routes->group('enrollment-collection-v1', static function ($routes) {
$routes->get('preview', 'EnrollmentCollectionV1DashboardController::preview');
$routes->get('preview/(:num)', 'EnrollmentCollectionV1DashboardController::preview/$1');
$routes->get('kpi/(:segment)', 'EnrollmentCollectionV1DashboardController::kpi/$1');
$routes->get('all', 'EnrollmentCollectionV1DashboardController::all');
$routes->get('debug', 'EnrollmentCollectionV1DashboardController::debug');
$routes->get('debug/(:num)', 'EnrollmentCollectionV1DashboardController::debug/$1');
});
});
$routes->post("policy_tranction/sendInstallmentRemainderMail","PolicyTransactionController::sendInstallmentRemainderMail");
@ -787,6 +796,15 @@ $routes->group("employeeRest", ["filter" => ['GlobalPostFileUploadGuard', 'ratel
$routes->get('debug', 'ClaimsCollectionV2DashboardController::debug');
$routes->get('debug/(:num)', 'ClaimsCollectionV2DashboardController::debug/$1');
});
$routes->group('enrollment-collection-v1', static function ($routes) {
$routes->get('preview', 'EnrollmentCollectionV1DashboardController::preview');
$routes->get('preview/(:num)', 'EnrollmentCollectionV1DashboardController::preview/$1');
$routes->get('kpi/(:segment)', 'EnrollmentCollectionV1DashboardController::kpi/$1');
$routes->get('all', 'EnrollmentCollectionV1DashboardController::all');
$routes->get('debug', 'EnrollmentCollectionV1DashboardController::debug');
$routes->get('debug/(:num)', 'EnrollmentCollectionV1DashboardController::debug/$1');
});
});
$routes->post("bulkEcardDownloadAsZip", "EmployeeRestController::bulkEcardDownloadAsZip");

View File

@ -0,0 +1,146 @@
<?php
namespace App\Controllers;
use App\Models\EnrollmentCollectionV1DashboardModel;
use CodeIgniter\API\ResponseTrait;
/**
* Enrollment Collection V1 KPI API (Metabase SQL as model methods).
*/
class EnrollmentCollectionV1DashboardController extends BaseController
{
use ResponseTrait;
protected function resolvePolicyId(?int $fallback = null): int
{
$id = (int) (
$this->request->getGet('client_policy')
?? $this->request->getGet('client_policy_id')
?? $this->request->getPost('client_policy')
?? $this->request->getPost('client_policy_id')
?? 0
);
if ($id > 0) {
return $id;
}
if ($fallback !== null && $fallback > 0) {
return $fallback;
}
return 0;
}
protected function resolveKpiMethod(string $kpiKey): ?string
{
$kpiKey = trim($kpiKey);
if ($kpiKey === '') {
return null;
}
if (in_array($kpiKey, EnrollmentCollectionV1DashboardModel::KPI_MAP, true)) {
return $kpiKey;
}
if (ctype_digit($kpiKey)) {
$id = (int) $kpiKey;
return EnrollmentCollectionV1DashboardModel::KPI_MAP[$id] ?? null;
}
return null;
}
public function kpi(string $kpiMethod = '')
{
$policyId = $this->resolvePolicyId();
if ($policyId <= 0) {
return $this->respond([
'status' => false,
'message' => 'client_policy or client_policy_id is required.',
], 422);
}
$kpiMethod = $this->resolveKpiMethod($kpiMethod);
if ($kpiMethod === null) {
return $this->respond([
'status' => false,
'message' => 'Unknown KPI. Pass Metabase id or method slug.',
'allowed' => EnrollmentCollectionV1DashboardModel::KPI_MAP,
], 404);
}
$model = new EnrollmentCollectionV1DashboardModel();
$metabaseId = array_search($kpiMethod, EnrollmentCollectionV1DashboardModel::KPI_MAP, true);
return $this->respond([
'status' => true,
'policy_id' => $policyId,
'kpi_id' => $metabaseId !== false ? (int) $metabaseId : null,
'kpi' => $kpiMethod,
'label' => EnrollmentCollectionV1DashboardModel::KPI_LABELS[$kpiMethod] ?? $kpiMethod,
'rows' => $model->getKpi($kpiMethod, $policyId),
]);
}
public function all()
{
$policyId = $this->resolvePolicyId();
if ($policyId <= 0) {
return $this->respond([
'status' => false,
'message' => 'client_policy or client_policy_id is required.',
], 422);
}
$model = new EnrollmentCollectionV1DashboardModel();
return $this->respond([
'status' => true,
'policy_id' => $policyId,
'data' => $model->getAllKpis($policyId),
]);
}
public function preview(int $policyId = 4687)
{
$policyId = $this->resolvePolicyId($policyId);
$path = $this->request->getUri()->getPath();
$isJwt = stripos($path, 'employeeRest') !== false;
$prefix = $isJwt ? 'employeeRest/enrollment-collection-v1' : 'util/enrollment-collection-v1';
return view('enrollment_collection_v1_dashboard', [
'policy_id' => $policyId,
'kpi_map' => EnrollmentCollectionV1DashboardModel::KPI_MAP,
'kpi_labels' => EnrollmentCollectionV1DashboardModel::KPI_LABELS,
'api_all_url' => base_url($prefix . '/all'),
'api_kpi_url' => base_url($prefix . '/kpi'),
]);
}
public function debug(int $policyId = 4687)
{
$policyId = $this->resolvePolicyId($policyId);
if ($policyId <= 0) {
return $this->response
->setStatusCode(422)
->setBody('client_policy or client_policy_id is required.');
}
$model = new EnrollmentCollectionV1DashboardModel();
$body = json_encode([
'status' => true,
'policy_id' => $policyId,
'data' => $model->getAllKpis($policyId),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
return $this->response
->setHeader('Content-Type', 'application/json; charset=UTF-8')
->setBody($body);
}
}

View File

@ -0,0 +1,290 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
/**
* Enrollment Collection V1 dashboard KPIs (from metabase_raw_queries.csv).
*/
class EnrollmentCollectionV1DashboardModel extends Model
{
protected $DBGroup = 'default';
/**
* Metabase question id => PHP method name.
*/
public const KPI_MAP = [
115 => 'originally_enrolled',
116 => 'added_subsequently',
117 => 'gender_split',
119 => 'enrollment_relationship',
122 => 'average_age_by_enrollment_month',
123 => 'original_base_premium',
124 => 'overall_active',
125 => 'enrollment_age_group',
126 => 'additions_premium',
127 => 'net_premium',
];
/**
* Human-readable KPI labels (Metabase name).
*/
public const KPI_LABELS = [
'originally_enrolled' => 'Originally Enrolled',
'added_subsequently' => 'Added Subsequently',
'gender_split' => 'Gender Split',
'enrollment_relationship' => 'Relationship',
'average_age_by_enrollment_month' => 'Average age by Enrolment month',
'original_base_premium' => 'Original Base Premium',
'overall_active' => 'Overall Active',
'enrollment_age_group' => 'Age Group',
'additions_premium' => 'Additions Premium',
'net_premium' => 'Net Premium',
];
protected function runKpiQuery(string $sql, int $policyId): array
{
$sql = str_replace(['\\t', '\\n', '\\r'], ["\t", "\n", "\r"], $sql);
$db = \Config\Database::connect($this->DBGroup);
$query = $db->query($sql, ['client_policy_id' => $policyId]);
return $query->getResultArray();
}
public function getKpi(string $method, int $policyId): array
{
if (! method_exists($this, $method)) {
return [];
}
return $this->{$method}($policyId);
}
public function getAllKpis(int $policyId): array
{
$out = [];
foreach (self::KPI_MAP as $id => $method) {
$out[$method] = [
'id' => (int) $id,
'label' => self::KPI_LABELS[$method] ?? $method,
'rows' => $this->getKpi($method, $policyId),
];
}
return $out;
}
/** Metabase #115: Originally Enrolled */
public function originally_enrolled(int $policyId): array
{
$sql = <<<'SQL'
SELECT
COUNT(DISTINCT ep.employee_id) AS `Originally Enrolled`
FROM employee_polices ep
INNER JOIN employees e ON ep.employee_id = e.id AND e.change_event = 'Inception'
WHERE ep.client_policy_id = :client_policy_id:
AND ep.is_active = 1
AND ep.status = 'Active'
AND e.emp_status = 'Active'
AND e.is_active = 1;
SQL;
return $this->runKpiQuery($sql, $policyId);
}
/** Metabase #116: Added Subsequently */
public function added_subsequently(int $policyId): array
{
$sql = <<<'SQL'
SELECT
COUNT(DISTINCT ep.employee_id) AS `Subsequent Additions`
FROM employee_polices ep
INNER JOIN employees e ON ep.employee_id = e.id
AND LOWER(e.change_event) IN ('dependent addition', 'addition', 'missed inception')
WHERE ep.client_policy_id = :client_policy_id:
AND ep.is_active = 1
AND ep.status = 'Active'
AND e.emp_status = 'Active'
AND e.is_active = 1;
SQL;
return $this->runKpiQuery($sql, $policyId);
}
/** Metabase #117: Gender Split */
public function gender_split(int $policyId): array
{
$sql = <<<'SQL'
SELECT
e.gender,
COUNT(DISTINCT ep.employee_id) AS member_count,
ROUND((COUNT(DISTINCT ep.employee_id) * 100.0) /
SUM(COUNT(DISTINCT ep.employee_id)) OVER(), 2) AS percentage
FROM employee_polices ep
INNER JOIN employees e ON ep.employee_id = e.id
WHERE ep.client_policy_id = :client_policy_id:
AND ep.is_active = 1
AND ep.status != 'Truncated'
AND e.is_active = 1
AND e.emp_status != 'Truncated'
AND e.gender IS NOT NULL
GROUP BY e.gender
ORDER BY member_count DESC;
SQL;
return $this->runKpiQuery($sql, $policyId);
}
/** Metabase #119: Relationship */
public function enrollment_relationship(int $policyId): array
{
$sql = <<<'SQL'
SELECT
e.relationship,
COUNT(DISTINCT ep.employee_id) AS member_count,
ROUND((COUNT(DISTINCT ep.employee_id) * 100.0) /
SUM(COUNT(DISTINCT ep.employee_id)) OVER(), 2) AS percentage
FROM employee_polices ep
INNER JOIN employees e ON ep.employee_id = e.id
WHERE ep.client_policy_id = :client_policy_id:
AND ep.is_active = 1
AND ep.status != 'Truncated'
AND e.is_active = 1
AND e.emp_status != 'Truncated'
AND e.relationship IS NOT NULL
GROUP BY e.relationship
ORDER BY member_count DESC;
SQL;
return $this->runKpiQuery($sql, $policyId);
}
/** Metabase #122: Average age by Enrolment month */
public function average_age_by_enrollment_month(int $policyId): array
{
$sql = <<<'SQL'
SELECT
DATE_FORMAT(ep.date_coverage, '%Y-%m') AS enrollment_month,
DATE_FORMAT(ep.date_coverage, '%b %Y') AS month_label,
ROUND(AVG(TIMESTAMPDIFF(YEAR, e.dob, ep.date_coverage)), 2) AS average_age,
COUNT(DISTINCT ep.employee_id) AS enrollments
FROM employee_polices ep
INNER JOIN employees e ON ep.employee_id = e.id
WHERE ep.client_policy_id = :client_policy_id:
AND ep.is_active = 1
AND e.is_active = 1
AND e.dob IS NOT NULL
AND ep.date_coverage IS NOT NULL
GROUP BY DATE_FORMAT(ep.date_coverage, '%Y-%m')
ORDER BY enrollment_month;
SQL;
return $this->runKpiQuery($sql, $policyId);
}
/** Metabase #123: Original Base Premium */
public function original_base_premium(int $policyId): array
{
$sql = <<<'SQL'
SELECT
ROUND(IFNULL(SUM(ep.rata_premimum + COALESCE(ep.gst, 0)), 0), 2) AS opening_premium
FROM employee_polices ep
INNER JOIN employees e ON ep.employee_id = e.id AND e.change_event = 'Inception'
WHERE ep.client_policy_id = :client_policy_id:
AND ep.is_active = 1
AND ep.status = 'Active'
AND e.emp_status = 'Active'
AND e.is_active = 1;
-- AND e.relationship = 'Self';
SQL;
return $this->runKpiQuery($sql, $policyId);
}
/** Metabase #124: Overall Active */
public function overall_active(int $policyId): array
{
$sql = <<<'SQL'
SELECT
COUNT(DISTINCT ep.employee_id) AS `Overall Active`
FROM employee_polices ep
INNER JOIN employees e ON ep.employee_id = e.id
WHERE ep.client_policy_id = :client_policy_id:
AND ep.is_active = 1
AND ep.status = 'Active'
AND e.emp_status = 'Active'
AND e.is_active = 1;
SQL;
return $this->runKpiQuery($sql, $policyId);
}
/** Metabase #125: Age Group */
public function enrollment_age_group(int $policyId): array
{
$sql = <<<'SQL'
SELECT
CASE
WHEN TIMESTAMPDIFF(YEAR, e.dob, CURDATE()) BETWEEN 0 AND 18 THEN '0-18'
WHEN TIMESTAMPDIFF(YEAR, e.dob, CURDATE()) BETWEEN 19 AND 30 THEN '19-30'
WHEN TIMESTAMPDIFF(YEAR, e.dob, CURDATE()) BETWEEN 31 AND 40 THEN '31-40'
WHEN TIMESTAMPDIFF(YEAR, e.dob, CURDATE()) BETWEEN 41 AND 50 THEN '41-50'
WHEN TIMESTAMPDIFF(YEAR, e.dob, CURDATE()) BETWEEN 51 AND 60 THEN '51-60'
ELSE '60+'
END AS age_group,
COUNT(DISTINCT ep.employee_id) AS member_count,
ROUND((COUNT(DISTINCT ep.employee_id) * 100.0) /
SUM(COUNT(DISTINCT ep.employee_id)) OVER(), 2) AS percentage
FROM employee_polices ep
INNER JOIN employees e ON ep.employee_id = e.id
WHERE ep.client_policy_id = :client_policy_id:
AND ep.is_active = 1
AND ep.status != 'Truncated'
AND e.is_active = 1
AND e.emp_status != 'Truncated'
AND e.dob IS NOT NULL
GROUP BY age_group
ORDER BY age_group;
SQL;
return $this->runKpiQuery($sql, $policyId);
}
/** Metabase #126: Additions Premium */
public function additions_premium(int $policyId): array
{
$sql = <<<'SQL'
SELECT
ROUND(IFNULL(SUM(ep.rata_premimum + COALESCE(ep.gst, 0)), 0), 2) AS additions_premium
FROM employee_polices ep
INNER JOIN employees e ON ep.employee_id = e.id
AND LOWER(e.change_event) IN ('dependent addition', 'addition', 'missed inception')
WHERE ep.client_policy_id = :client_policy_id:
AND ep.is_active = 1
AND ep.status = 'Active'
AND e.is_active = 1
AND e.emp_status = 'Active';
SQL;
return $this->runKpiQuery($sql, $policyId);
}
/** Metabase #127: Net Premium */
public function net_premium(int $policyId): array
{
$sql = <<<'SQL'
SELECT
ROUND(IFNULL(SUM(ep.rata_premimum + COALESCE(ep.gst, 0)), 0), 2) AS net_premium
-- ROUND(SUM(ep.rata_premimum), 2) as base_premium,
-- ROUND(SUM(COALESCE(ep.gst, 0)), 2) as total_gst
FROM employee_polices ep
WHERE ep.client_policy_id = :client_policy_id:
AND ep.is_active = 1
AND ep.status = 'Active';
SQL;
return $this->runKpiQuery($sql, $policyId);
}
}

View File

@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Enrollment Collection V1 Dashboard</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; padding: 1.5rem; background: #f0f2f5; color: #1a1a1a; }
h1 { font-size: 1.35rem; margin: 0 0 1rem; }
.toolbar { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; margin-bottom: 1.25rem; }
.toolbar input { padding: 0.45rem 0.6rem; border: 1px solid #ccc; border-radius: 6px; width: 140px; }
.toolbar button { padding: 0.5rem 1rem; border: none; border-radius: 6px; background: #059669; color: #fff; cursor: pointer; }
.toolbar button.secondary { background: #64748b; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.card { background: #fff; border-radius: 8px; padding: 1rem; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.card h2 { font-size: 0.85rem; margin: 0 0 0.5rem; color: #475569; font-weight: 600; }
.card pre { font-size: 0.72rem; margin: 0; max-height: 200px; overflow: auto; background: #f8fafc; padding: 0.5rem; border-radius: 4px; white-space: pre-wrap; word-break: break-word; }
.card .status { font-size: 0.75rem; color: #94a3b8; margin-bottom: 0.35rem; }
.card.loading pre { color: #94a3b8; }
.card.error pre { color: #b91c1c; }
</style>
</head>
<body>
<h1>Enrollment Collection V1</h1>
<div class="toolbar">
<label>Policy ID <input type="number" id="policy-id" value="<?= (int) $policy_id ?>" min="1"></label>
<button type="button" id="btn-load-all">Load all KPIs</button>
<button type="button" class="secondary" id="btn-clear">Clear</button>
</div>
<div class="grid" id="kpi-grid">
<?php foreach ($kpi_map as $metabaseId => $method): ?>
<div class="card" id="card-<?= esc($method) ?>" data-kpi="<?= esc($method) ?>">
<div class="status">#<?= (int) $metabaseId ?> · <?= esc($kpi_labels[$method] ?? $method) ?></div>
<h2><?= esc($method) ?></h2>
<pre>Click “Load all KPIs” or open single KPI API.</pre>
</div>
<?php endforeach; ?>
</div>
<script>
(function () {
const policyInput = document.getElementById('policy-id');
const grid = document.getElementById('kpi-grid');
function setCardState(method, state, text) {
const card = document.getElementById('card-' + method);
if (!card) return;
card.classList.remove('loading', 'error');
if (state) card.classList.add(state);
card.querySelector('pre').textContent = text;
}
async function loadAll() {
const policyId = policyInput.value;
if (!policyId) {
alert('Enter a policy ID');
return;
}
grid.querySelectorAll('.card').forEach(function (c) {
c.classList.add('loading');
c.querySelector('pre').textContent = 'Loading…';
});
try {
const apiAllUrl = <?= json_encode($api_all_url ?? base_url('util/enrollment-collection-v1/all')) ?>;
const res = await fetch(apiAllUrl + '?client_policy=' + encodeURIComponent(policyId));
const json = await res.json();
if (!json.status) {
throw new Error(json.message || 'Request failed');
}
Object.keys(json.data || {}).forEach(function (method) {
const block = json.data[method];
setCardState(method, '', JSON.stringify(block.rows, null, 2));
});
} catch (e) {
grid.querySelectorAll('.card').forEach(function (c) {
c.classList.remove('loading');
c.classList.add('error');
c.querySelector('pre').textContent = e.message;
});
} finally {
grid.querySelectorAll('.card.loading').forEach(function (c) {
c.classList.remove('loading');
});
}
}
document.getElementById('btn-load-all').addEventListener('click', loadAll);
document.getElementById('btn-clear').addEventListener('click', function () {
grid.querySelectorAll('.card').forEach(function (c) {
c.classList.remove('error');
c.querySelector('pre').textContent = '—';
});
});
})();
</script>
</body>
</html>

154
enrollment-dashboard.md Normal file
View File

@ -0,0 +1,154 @@
# Enrollment Collection V1 — HR Dashboard API
**Created:** 2026-06-03
**Controller:** `App\Controllers\EnrollmentCollectionV1DashboardController`
**Model:** `App\Models\EnrollmentCollectionV1DashboardModel`
**Source queries:** `metabase_raw_queries.csv` → collection `Enrollment Collection V1`
**Base app URL (local):** `https://localhost/PHP828APPS/ruc/nhance/index.php`
**Total KPIs:** 10
---
## Overview
All Enrollment Collection V1 KPIs from **Enrollment Collection V1** are implemented as PHP model methods.
SQL uses Metabase param `{{client_policy_id}}` → CodeIgniter bind `:client_policy_id:`.
| What | URL fragment | Use case |
|------|-------------|----------|
| **Single KPI** | `kpi/{slug or Metabase id}` | One card at a time |
| **All KPIs** | `all` | Full enrollment dashboard (10 KPIs) |
| **Debug / preview** | `debug` / `preview` | Admin / FE testing |
Query param: `client_policy` or `client_policy_id` (required for `kpi` / `all`).
---
## Route stacks
### 1. Admin — `authMVC`
Prefix: `util/enrollment-collection-v1`
| Method | Path | Purpose |
|--------|------|---------|
| GET | `util/enrollment-collection-v1/preview` | UI debug grid |
| GET | `util/enrollment-collection-v1/preview/{policy_id}` | Same, policy in URL |
| GET | `util/enrollment-collection-v1/debug` | Raw JSON (all KPIs) |
| GET | `util/enrollment-collection-v1/debug/{policy_id}` | Same, policy in URL |
| GET | `util/enrollment-collection-v1/all` | JSON — all 6 KPIs |
| GET | `util/enrollment-collection-v1/kpi/{slug\|id}` | JSON — single KPI |
### 2. Frontend / HR App — `authJWT`
Prefix: `employeeRest/enrollment-collection-v1`
Same paths as above under `employeeRest/` (Bearer + `X-App-Signature`).
---
## How to call
> **Important:** Admin routes live under the **`util/`** prefix (same as `util/claims-collection-v2`).
> `enrollment-collection-v1/preview` without `util/` will return **404**.
```
GET /index.php/util/enrollment-collection-v1/preview?client_policy=4687
GET /index.php/util/enrollment-collection-v1/all?client_policy=4687
GET /index.php/util/enrollment-collection-v1/kpi/originally_enrolled?client_policy=4687
GET /index.php/util/enrollment-collection-v1/kpi/117?client_policy=4687
GET /index.php/util/enrollment-collection-v1/debug?client_policy=4687
```
Full local example:
```
https://localhost/PHP828APPS/ruc/nhance/index.php/util/enrollment-collection-v1/preview?client_policy=4687
```
---
## All 10 KPIs
| Metabase ID | Method slug | Label | Rows |
|-------------|-------------|-------|------|
| 115 | `originally_enrolled` | Originally Enrolled | single |
| 116 | `added_subsequently` | Added Subsequently | single |
| 117 | `gender_split` | Gender Split | multi |
| 119 | `enrollment_relationship` | Relationship | multi |
| 122 | `average_age_by_enrollment_month` | Average age by Enrolment month | multi |
| 123 | `original_base_premium` | Original Base Premium | single |
| 124 | `overall_active` | Overall Active | single |
| 125 | `enrollment_age_group` | Age Group | multi |
| 126 | `additions_premium` | Additions Premium | single |
| 127 | `net_premium` | Net Premium | single |
> Slug `enrollment_relationship` avoids clashing with Claims V2 `claim_amount_by_relationship` (ticket-level).
---
## Output columns
| Slug | Key row fields |
|------|----------------|
| `originally_enrolled` | `Originally Enrolled` |
| `added_subsequently` | `Subsequent Additions` |
| `gender_split` | `gender`, `member_count`, `percentage` |
| `enrollment_relationship` | `relationship`, `member_count`, `percentage` |
| `average_age_by_enrollment_month` | `enrollment_month`, `month_label`, `average_age`, `enrollments` |
| `original_base_premium` | `opening_premium` |
| `overall_active` | `Overall Active` |
| `enrollment_age_group` | `age_group`, `member_count`, `percentage` |
| `additions_premium` | `additions_premium` |
| `net_premium` | `net_premium` |
---
## Sample `all` response (excerpt)
```json
{
"status": true,
"policy_id": 4687,
"data": {
"originally_enrolled": {
"id": 115,
"label": "Originally Enrolled",
"rows": [{ "Originally Enrolled": "320" }]
},
"gender_split": {
"id": 117,
"label": "Gender Split",
"rows": [
{ "gender": "Male", "member_count": "180", "percentage": "56.25" }
]
}
}
}
```
---
## Files
| File | Purpose |
|------|---------|
| `app/Models/EnrollmentCollectionV1DashboardModel.php` | KPI SQL, `KPI_MAP`, `KPI_LABELS` |
| `app/Controllers/EnrollmentCollectionV1DashboardController.php` | `kpi()`, `all()`, `preview()`, `debug()` |
| `app/Views/enrollment_collection_v1_dashboard.php` | Preview UI |
| `app/Config/Routes.php` | `enrollment-collection-v1` groups (util + employeeRest) |
| `tests/smoke_enrollment_collection_v1.php` | `php tests/smoke_enrollment_collection_v1.php 4687` |
| `enrollment-dashboard.md` | This document |
---
## Related
- Claims dashboard: `hr-dashboard.md``claims-collection-v2` (36 KPIs)
---
## BE notes
- Run `php tests/smoke_enrollment_collection_v1.php {policy_id}` after model changes.
- Status filters in CSV use `'Active'` (capital A) for some KPIs; kept as in Metabase export.

View File

@ -407,3 +407,9 @@ X-App-Signature: <app_signature>
- All queries use named binding `:policy_id:` (CodeIgniter style, replaces Metabase `{{policy_id}}`).
- Literal `\t` / `\n` in CSV SQL is normalized in `runKpiQuery()` — safe to re-generate from CSV.
- Run `php tests/smoke_claims_collection_v2.php {policy_id}` after any model change (expects `KPI_MAP` count === 36).
---
## Related dashboards
- **Enrollment Collection V1** (10 KPIs): see `enrollment-dashboard.md` — prefix `enrollment-collection-v1`

View File

@ -0,0 +1,105 @@
<?php
/**
* Smoke test: Enrollment Collection V1 dashboard.
* Run: php tests/smoke_enrollment_collection_v1.php [policy_id]
*/
declare(strict_types=1);
use App\Controllers\EnrollmentCollectionV1DashboardController;
use App\Models\EnrollmentCollectionV1DashboardModel;
use Config\Services;
ob_start();
define('FCPATH', __DIR__ . '/../public/');
chdir(FCPATH);
require FCPATH . '../app/Config/Paths.php';
$paths = new Config\Paths();
require rtrim($paths->systemDirectory, '\\/ ') . DIRECTORY_SEPARATOR . 'bootstrap.php';
require_once SYSTEMPATH . 'Config/DotEnv.php';
(new CodeIgniter\Config\DotEnv(ROOTPATH))->load();
defined('ENVIRONMENT') || define('ENVIRONMENT', env('CI_ENVIRONMENT', 'development'));
$boot = APPPATH . 'Config/Boot/' . ENVIRONMENT . '.php';
if (is_file($boot)) {
require_once $boot;
}
helper('url');
$policyId = isset($argv[1]) ? (int) $argv[1] : 4687;
$pass = 0;
$fail = 0;
$results = [];
function ok_enrollment(string $label, bool $cond, string $detail = ''): void
{
global $pass, $fail, $results;
if ($cond) {
$pass++;
$results[] = '[PASS] ' . $label . ($detail ? "{$detail}" : '');
} else {
$fail++;
$results[] = '[FAIL] ' . $label . ($detail ? "{$detail}" : '');
}
}
$model = new EnrollmentCollectionV1DashboardModel();
$kpiMap = EnrollmentCollectionV1DashboardModel::KPI_MAP;
$expectedCount = 10;
ok_enrollment('KPI_MAP count', count($kpiMap) === $expectedCount, (string) count($kpiMap));
$uniqueMethods = array_unique(array_values($kpiMap));
ok_enrollment('unique KPI method names', count($uniqueMethods) === $expectedCount, count($uniqueMethods) . ' methods');
ok_enrollment('id 115 maps to originally_enrolled', ($kpiMap[115] ?? '') === 'originally_enrolled');
ok_enrollment('id 119 maps to enrollment_relationship', ($kpiMap[119] ?? '') === 'enrollment_relationship');
ok_enrollment('id 124 maps to overall_active', ($kpiMap[124] ?? '') === 'overall_active');
ok_enrollment('id 125 maps to enrollment_age_group', ($kpiMap[125] ?? '') === 'enrollment_age_group');
ok_enrollment('id 126 maps to additions_premium', ($kpiMap[126] ?? '') === 'additions_premium');
ok_enrollment('id 127 maps to net_premium', ($kpiMap[127] ?? '') === 'net_premium');
try {
$rows = $model->originally_enrolled($policyId);
ok_enrollment('model originally_enrolled', is_array($rows), 'rows=' . count($rows));
} catch (Throwable $e) {
ok_enrollment('model originally_enrolled', false, $e->getMessage());
}
$request = Services::request(null, false);
$response = Services::response();
$request->setGlobal('get', ['client_policy' => (string) $policyId]);
$controller = new EnrollmentCollectionV1DashboardController();
$controller->initController($request, $response, service('logger'));
$slugResp = json_decode($controller->kpi('gender_split')->getJSON(), true);
ok_enrollment('controller kpi by slug', ($slugResp['status'] ?? false) === true && ($slugResp['kpi'] ?? '') === 'gender_split');
$idResp = json_decode($controller->kpi('115')->getJSON(), true);
ok_enrollment('controller kpi by id 115', ($idResp['status'] ?? false) === true && ($idResp['kpi_id'] ?? 0) === 115);
$allResp = json_decode($controller->all()->getJSON(), true);
ok_enrollment('controller all KPIs', ($allResp['status'] ?? false) === true && count($allResp['data'] ?? []) === $expectedCount);
$previewOut = $controller->preview($policyId);
$previewHtml = is_string($previewOut) ? $previewOut : $previewOut->getBody();
ok_enrollment('controller preview HTML', str_contains($previewHtml, 'Enrollment Collection V1') && str_contains($previewHtml, 'kpi-grid'));
$sparkRoutes = (string) shell_exec('php ' . escapeshellarg(ROOTPATH . 'spark') . ' routes 2>&1');
ok_enrollment('spark lists util preview route', str_contains($sparkRoutes, 'util/enrollment-collection-v1/preview'));
ok_enrollment('spark lists employeeRest preview route', str_contains($sparkRoutes, 'employeeRest/enrollment-collection-v1/preview'));
$previewUrl = rtrim((string) base_url(), '/') . '/util/enrollment-collection-v1/preview?client_policy=' . $policyId;
ob_end_clean();
echo '=== Enrollment Collection V1 smoke test (policy_id=' . $policyId . ') ===' . PHP_EOL . PHP_EOL;
echo implode(PHP_EOL, $results) . PHP_EOL;
echo PHP_EOL . '=== Admin preview URL (note util/ prefix) ===' . PHP_EOL;
echo $previewUrl . PHP_EOL;
echo PHP_EOL . "=== Summary: {$pass} passed, {$fail} failed ===" . PHP_EOL;
exit($fail > 0 ? 1 : 0);