989 lines
45 KiB
PHP
989 lines
45 KiB
PHP
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>PBC Doctor Analytics</title>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script>
|
||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.js"></script>
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@3.19.0/dist/tabler-icons.min.css">
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&display=swap" rel="stylesheet">
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
:root {
|
||
--bg: #f5f5f3;
|
||
--surface-0: #ececea;
|
||
--surface-1: #f3f7f9;
|
||
--surface-2: #ffffff;
|
||
--text-primary: #343a40;
|
||
--text-secondary: #6c757d;
|
||
--text-muted: #706f6b;
|
||
--text-gray: #999;
|
||
--border: rgba(11,11,11,0.10);
|
||
--border-strong: rgba(11,11,11,0.18);
|
||
--radius: 8px;
|
||
--radius-sm: 4px;
|
||
--accent-bg: #ece9ff;
|
||
--accent-border: #a198f5;
|
||
--accent-text: #6658dd;
|
||
--accent-text-dark: #574bbc;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Nunito Sans', sans-serif;
|
||
background: #fff;
|
||
color: var(--text-primary);
|
||
min-height: 100vh;
|
||
font-size: 14px;
|
||
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.page { max-width: 1200px; margin: 0 auto; padding: 1.5rem 1.25rem 3rem; }
|
||
|
||
/* Top bar */
|
||
.top-bar { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: .5rem; flex-wrap: wrap; gap: 12px; }
|
||
.top-bar h1 { font-size: 1.125rem; font-weight: 600; color: var(--text-primary); display: flex; align-items: center; gap: 8px; }
|
||
.top-bar h1 i { color: var(--accent-text); font-size: 22px; }
|
||
.top-bar p { font-size: 13px; color: var(--text-secondary); margin-top: 3px; }
|
||
.btn { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; padding: 7px 14px; border-radius: var(--radius); border: 0.5px solid var(--border-strong); background: var(--surface-2); color: var(--text-primary); cursor: pointer; transition: background .15s; font-family: inherit; }
|
||
.btn:hover { background: var(--surface-1); }
|
||
|
||
/* Upload zone */
|
||
.upload-zone { border: 1.5px dashed var(--border-strong); border-radius: 12px; padding: 1.25rem; text-align: center; cursor: pointer; transition: background .15s; margin-bottom: 1.5rem; background: var(--surface-2); }
|
||
.upload-zone:hover { background: var(--surface-1); }
|
||
.upload-zone i { font-size: 26px; color: var(--text-muted); display: block; margin-bottom: 6px; }
|
||
.upload-zone p { font-size: 13px; color: var(--text-secondary); }
|
||
.upload-zone small { font-size: 11px; color: var(--text-muted); }
|
||
|
||
/* KPI row */
|
||
.kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 8px; margin-bottom: 1.5rem; }
|
||
.kpi { background: var(--surface-1); border-radius: var(--radius); padding: .3rem 0.4rem; cursor: pointer; border: 1px solid transparent; transition: border-color .15s, background .15s; }
|
||
.kpi:hover { border-color: var(--border); }
|
||
.kpi.kpi-active { border-color: var(--accent-border); background: var(--accent-bg); }
|
||
.kpi.kpi-active .kpi-label { color: var(--accent-text); }
|
||
.kpi-label { font-size: 11px; color: var(--text-muted); margin-bottom: 4px; letter-spacing: .05em; }
|
||
.kpi-val { font-size: 18px; font-weight: 600; color: var(--text-primary); }
|
||
.kpi-val.danger { color: #e24b4a; }
|
||
.kpi-val.warn { color: #ba7517; }
|
||
.kpi-val.safe { color: #3b6d11; }
|
||
.kpi-hint { font-size: 10px; color: var(--text-muted); margin-top: 4px; display: flex; align-items: center; gap: 3px; }
|
||
|
||
/* Month tabs */
|
||
.controls { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 1.25rem; }
|
||
.controls label { font-size: 14px; color: var(--text-secondary); white-space: nowrap; font-weight: 600; }
|
||
.month-tabs { display: flex; gap: 4px; flex-wrap: wrap; }
|
||
.mtab { font-size: 12px; padding: 5px 12px; border-radius: 20px; border: 0.5px solid var(--border-strong); cursor: pointer; background: var(--surface-2); color: var(--text-secondary); transition: all .12s; white-space: nowrap; font-family: inherit; }
|
||
.mtab:hover { background: var(--surface-1); }
|
||
.mtab.active { background: var(--accent-text); border-color: var(--accent-border); color: #fff; font-weight: 500; }
|
||
|
||
/* Filter section */
|
||
.filter-section { background: var(--surface-2); border: 0.5px solid var(--border); border-radius: 5px; padding: 1.125rem; margin-bottom: 0.5rem; }
|
||
.filter-section-hdr { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; cursor: pointer; user-select: none; }
|
||
.filter-section-hdr h3 { font-size: 1.125rem; font-weight: 600; color: var(--text-primary); display: flex; align-items: center; gap: 5px; }
|
||
.filter-toggle-icon { font-size: 18px; color: var(--text-secondary); transition: transform .2s; }
|
||
.filter-section.open .filter-toggle-icon { transform: rotate(180deg); }
|
||
.filter-section-body { display: none; margin-top: .875rem; }
|
||
.filter-section.open .filter-section-body { display: block; }
|
||
.filter-clear { font-size: 14px; color: var(--accent-text); cursor: pointer; border: none; background: none; padding: 0; display: flex; align-items: center; gap: 4px; font-family: inherit; }
|
||
.filter-clear:hover { text-decoration: none; color: var(--accent-text-dark); }
|
||
.filter-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 14px; }
|
||
.filter-group { display: flex; flex-direction: column; gap: 5px; }
|
||
.filter-label { font-size: 16px; font-weight: 600; color: var(--text-secondary); margin-bottom: 0.5rem; }
|
||
.filter-input-row { display: flex; gap: 5px; align-items: center; }
|
||
.filter-input-row select,
|
||
.filter-input-row input[type=number] { font-size: 14px; min-height: 38px; padding: 0.45rem 0.9rem; border-radius: var(--radius-sm); border: 1px solid #ced4da; background: #fff; color: var(--text-secondary); min-width: 0; flex: 1; font-family: inherit; appearance: none; }
|
||
.filter-input-row select:focus,
|
||
.filter-input-row input:focus { outline: 0px solid var(--text-secondary); outline-offset: 0px; border-color: #b1bbc4; }
|
||
.filter-add { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 36px; border-radius: var(--radius-sm); border: 1px solid #b1bbc4; cursor: pointer; background: var(--accent-text); color: #fff; flex-shrink: 0; transition: background .12s; }
|
||
.filter-add:hover { background: var(--accent-text-dark); color: #fff; }
|
||
.filter-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; min-height: 0px; }
|
||
.chip { display: inline-flex; align-items: center; gap: 3px; font-size: 10px; padding: 1px 5px; border-radius: 4px; border: 0.5px solid var(--border-strong); background: var(--surface-1); color: var(--text-secondary); }
|
||
.chip-roi { background: #fcebeb; color: #a32d2d; border-color: #f09595; }
|
||
.chip-avg { background: #faeeda; color: #854f0b; border-color: #fac775; }
|
||
.chip-ps { background: #e1f5ee; color: #0f6e56; border-color: #5dcaa5; }
|
||
.chip-srch { background: var(--accent-bg); color: var(--accent-text); border-color: var(--accent-border); }
|
||
.chip-rm { cursor: pointer; font-size: 13px; line-height: 1; margin-left: 2px; opacity: .65; }
|
||
.chip-rm:hover { opacity: 1; }
|
||
.srch-wrap { display: flex; gap: 5px; align-items: center; }
|
||
.srch { min-height: 38px; padding: 0.45rem 0.9rem; border-radius: var(--radius-sm); border: 1px solid #ced4da; font-size: 14px; min-width: 0; flex: 1; background: #fff; color: var(--text-secondary); font-family: inherit; }
|
||
.srch:focus { outline: 0px solid var(--accent-border); outline-offset: 0px; border-color: #b1bbc4; }
|
||
.active-filter-desc { font-size: 12px; color: var(--text-muted); margin-top: .625rem; padding-top: .625rem; border-top: 0.5px solid var(--border); }
|
||
|
||
/* Table */
|
||
.tbl-wrap { overflow-x: auto; border: 0.5px solid var(--border); border-left: none; border-right: none; border-radius: 5px; background: var(--surface-2); margin-bottom: .5rem; }
|
||
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 14px; }
|
||
thead th { padding: 0.85rem 0.85rem; text-align: left; font-size: 14px; font-weight: 600; color: var(--text-secondary); letter-spacing: .05em; border-bottom: 1px solid var(--border); background: var(--surface-1); white-space: nowrap; }
|
||
tbody tr { border-bottom: 1px solid var(--border); cursor: pointer; transition: background .1s; }
|
||
tbody tr:last-child { border-bottom: none; }
|
||
/* tbody tr:hover { background: var(--surface-1); } */
|
||
tbody tr.sel { background: var(--accent-bg); }
|
||
td { padding: 0.85rem 0.85rem; color: var(--text-secondary); vertical-align: top; }
|
||
td.tc { vertical-align: middle; }
|
||
.col-freeze { position: sticky; z-index: 1; background: var(--surface-2); }
|
||
thead .col-freeze { z-index: 3; background: var(--surface-1); }
|
||
tbody tr.sel .col-freeze { background: var(--accent-bg); }
|
||
.col-freeze-doctor { left: 0; min-width: 160px; box-shadow: 4px 0 6px -2px rgba(0, 0, 0, 0.08); }
|
||
/* .dr { font-weight: 600; color: var(--accent-text); } */
|
||
.badge { display: inline-flex; align-items: center; gap: 3px; font-size: 11px; padding: 2px 7px; border-radius: 4px; font-weight: 500; margin: 2px 3px 2px 0; white-space: nowrap; }
|
||
.b-roi { background: #fcebeb; color: #a32d2d; }
|
||
.b-avg { background: #faeeda; color: #854f0b; }
|
||
.b-ps { background: #e1f5ee; color: #0f6e56; }
|
||
.flags-cell { display: flex; flex-wrap: wrap; align-items: flex-start; }
|
||
.trend-up { color: #3b6d11; font-size: 16px; }
|
||
.trend-down { color: #e24b4a; font-size: 16px; }
|
||
.trend-flat { color: var(--text-muted); font-size: 14px; }
|
||
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--text-muted); }
|
||
.empty-state i { font-size: 36px; display: block; margin-bottom: 8px; }
|
||
|
||
/* Doctor detail modal */
|
||
#doctorModal .modal-content { border: 0.5px solid var(--border); border-radius: 4px; background: var(--surface-2); }
|
||
#doctorModal .modal-header { border-bottom: 0.5px solid var(--border); padding: 1rem 1.25rem; align-items: flex-start; gap: 12px; }
|
||
#doctorModal .modal-body { padding: 1rem 1.25rem 1.25rem; }
|
||
#doctorModal .modal-title { font-size: 16px; font-weight: 600; color: var(--text-primary); }
|
||
.chart-sub { font-size: 12px; color: var(--text-secondary); margin-top: 2px; margin-bottom: 0; }
|
||
.chart-hdr-row { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: .875rem; flex-wrap: wrap; gap: 8px; }
|
||
.chart-legend { display: flex; flex-wrap: wrap; gap: 14px; margin-bottom: 8px; font-size: 12px; color: var(--text-secondary); }
|
||
.cli { display: flex; align-items: center; gap: 5px; }
|
||
.cls { width: 10px; height: 10px; border-radius: 2px; }
|
||
.chart-mini-kpis { display: flex; gap: 10px; flex-wrap: wrap; }
|
||
.mini-kpi { background: var(--surface-1); border-radius: var(--radius); padding: .625rem .875rem; min-width: 90px; }
|
||
.mini-kpi .kpi-label { font-size: 10px; }
|
||
.mini-kpi .kpi-val { font-size: 16px; }
|
||
.cw { position: relative; width: 100%; height: 260px; }
|
||
|
||
/* Tag legend row */
|
||
.tag-legend { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 1rem; align-items: center; }
|
||
.tag { font-size: 12px; padding: 3px 6px; border-radius: 4px; border: 0.5px solid; display: inline-flex; align-items: center; gap: 4px; }
|
||
.tag-roi { background: #fcebeb; color: #a32d2d; border-color: #f09595; }
|
||
.tag-avg { background: #faeeda; color: #854f0b; border-color: #fac775; }
|
||
.tag-ps { background: #e1f5ee; color: #0f6e56; border-color: #5dcaa5; }
|
||
.tag-info { font-size: 12px; color: var(--text-muted); display: flex; align-items: center; gap: 4px; }
|
||
|
||
footer { font-size: 11px; color: var(--text-muted); text-align: center; margin-top: 2rem; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
|
||
<div class="top-bar">
|
||
<div>
|
||
<!-- <h1><i class="ti ti-activity"></i>PBS Doctor Analytics</h1> -->
|
||
<h1>PBC Doctor Analytics</h1>
|
||
<p>Click a KPI to filter · select a month to evaluate · click a doctor to view details</p>
|
||
</div>
|
||
<!-- <button class="btn" onclick="document.getElementById('fi').click()">
|
||
<i class="ti ti-upload"></i> Load tracker
|
||
</button> -->
|
||
<!-- <input type="file" id="fi" accept=".xlsx,.xls" style="display:none" onchange="handleFile(event)"> -->
|
||
</div>
|
||
|
||
<!-- <div id="uz" class="upload-zone" onclick="document.getElementById('fi').click()">
|
||
<i class="ti ti-table-import"></i>
|
||
<p>Drop your PBS_Tracker.xlsx here or click to browse</p>
|
||
<small>Columns must follow PBS format (Sr No, Doctor Name, PBC Amt, monthly Total / Pulmosmart / % PS columns)</small>
|
||
</div> -->
|
||
|
||
<div id="main">
|
||
<div class="filter-section border-0 px-0" id="filterSection">
|
||
<div class="filter-section-hdr" onclick="toggleFilterSection()" role="button" tabindex="0" aria-expanded="false" aria-controls="filterBody" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleFilterSection()}">
|
||
<h3>Filters <i class="ti ti-chevron-down filter-toggle-icon"></i></h3>
|
||
<button class="filter-clear" onclick="event.stopPropagation();clearAllFilters()">Clear all</button>
|
||
</div>
|
||
<div class="filter-section-body" id="filterBody">
|
||
<div class="filter-grid">
|
||
|
||
<div class="filter-group">
|
||
<div class="filter-label">Brand</div>
|
||
<div class="filter-input-row">
|
||
<select id="brand-filter" onchange="onBrandChange()"></select>
|
||
</div>
|
||
</div>
|
||
|
||
@if(!empty($allDataAccess))
|
||
<div class="filter-group">
|
||
<div class="filter-label">Zone</div>
|
||
<div class="filter-input-row">
|
||
<select id="hier-zone" onchange="onHierChange('zone')"></select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filter-group">
|
||
<div class="filter-label">Region</div>
|
||
<div class="filter-input-row">
|
||
<select id="hier-region" onchange="onHierChange('region')"></select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filter-group">
|
||
<div class="filter-label">Area</div>
|
||
<div class="filter-input-row">
|
||
<select id="hier-area" onchange="onHierChange('area')"></select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filter-group">
|
||
<div class="filter-label">Territory</div>
|
||
<div class="filter-input-row">
|
||
<select id="hier-territory" onchange="onHierChange('territory')"></select>
|
||
</div>
|
||
</div>
|
||
@endif
|
||
|
||
<div class="filter-group">
|
||
<div class="filter-label">ROI threshold (×)</div>
|
||
<div class="filter-input-row">
|
||
<select id="roi-op">
|
||
<option value="lt">Less than</option>
|
||
<option value="gt">Greater than</option>
|
||
<option value="eq">Equal to</option>
|
||
</select>
|
||
<input type="number" id="roi-val" placeholder="5" min="0" step="0.1" style="width:64px">
|
||
<button class="filter-add" onclick="addFilter('roi')" title="Add filter"><i class="ti ti-plus"></i></button>
|
||
</div>
|
||
<div class="filter-chips" id="chips-roi"></div>
|
||
</div>
|
||
|
||
<div class="filter-group">
|
||
<div class="filter-label">Avg monthly sales</div>
|
||
<div class="filter-input-row">
|
||
<select id="avg-op">
|
||
<option value="lt">Below avg</option>
|
||
<option value="gt">Above avg</option>
|
||
<option value="pct-lt">Sel month < avg%</option>
|
||
</select>
|
||
<input type="number" id="avg-val" placeholder="100" min="0" step="1" style="width:64px">
|
||
<button class="filter-add" onclick="addFilter('avg')" title="Add filter"><i class="ti ti-plus"></i></button>
|
||
</div>
|
||
<div class="filter-chips" id="chips-avg"></div>
|
||
</div>
|
||
|
||
<div class="filter-group">
|
||
<div class="filter-label">Brand % (sel month)</div>
|
||
<div class="filter-input-row">
|
||
<select id="ps-op">
|
||
<option value="lt">Less than</option>
|
||
<option value="gt">Greater than</option>
|
||
</select>
|
||
<input type="number" id="ps-val" placeholder="70" min="0" max="100" step="1" style="width:64px">
|
||
<button class="filter-add" onclick="addFilter('ps')" title="Add filter"><i class="ti ti-plus"></i></button>
|
||
</div>
|
||
<div class="filter-chips" id="chips-ps"></div>
|
||
</div>
|
||
|
||
<div class="filter-group">
|
||
<div class="filter-label">Search</div>
|
||
<div class="srch-wrap">
|
||
<input class="srch" type="search" id="srchIn" placeholder="Doctor name or HQ…" oninput="setSearch(this.value)" aria-label="Search doctors">
|
||
</div>
|
||
<div class="filter-chips" id="chips-srch"></div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
<div class="active-filter-desc" id="activeFilterDesc"></div>
|
||
</div>
|
||
|
||
<div class="kpi-row" id="kpiRow"></div>
|
||
|
||
<div class="controls">
|
||
<label><i class="ti ti-calendar" style="margin-right:3px"></i>View month:</label>
|
||
<div class="month-tabs" id="monthTabs"></div>
|
||
</div>
|
||
|
||
<div class="tag-legend">
|
||
<span class="tag tag-roi"><i class="ti ti-alert-triangle"></i> ROI < 5×</span>
|
||
<span class="tag tag-avg"><i class="ti ti-trending-down"></i> Below avg sales</span>
|
||
<span class="tag tag-ps"><i class="ti ti-leaf"></i> Brand% < 70%</span>
|
||
<span class="tag-info">
|
||
<i class="ti ti-arrow-up" style="color:#3b6d11"></i>gaining
|
||
<i class="ti ti-arrow-down" style="color:#e24b4a"></i>falling vs prior month
|
||
</span>
|
||
</div>
|
||
|
||
<div class="tbl-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th class="col-freeze col-freeze-doctor" style="min-width:160px">Doctor</th>
|
||
<th style="width:90px">Zone</th>
|
||
<th style="width:90px">Region</th>
|
||
<th style="width:90px">Area</th>
|
||
<th style="width:80px">HQ</th>
|
||
<th style="width:100px">PBC spend</th>
|
||
<th style="width:110px">Cumul. sales</th>
|
||
<th style="width:70px">ROI</th>
|
||
<th style="width:110px">Avg monthly</th>
|
||
<th style="width:120px">Sel month sales</th>
|
||
<th style="width:70px">Brand%</th>
|
||
<th style="min-width:200px">Flags</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tb"></tbody>
|
||
</table>
|
||
<div id="noData" class="empty-state" style="display:none">
|
||
<i class="ti ti-mood-confuzed"></i>
|
||
<p>No doctors match the current filters.</p>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- <footer>PBS Doctor Analytics · Upload your own tracker to get started</footer> -->
|
||
</div>
|
||
|
||
<div class="modal fade" id="doctorModal" tabindex="-1" aria-labelledby="doctorModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<div>
|
||
<h5 class="modal-title" id="doctorModalLabel"></h5>
|
||
<p class="chart-sub" id="csub"></p>
|
||
</div>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="chart-hdr-row">
|
||
<div class="chart-mini-kpis" id="cminis"></div>
|
||
</div>
|
||
<div class="chart-legend" id="cleg"></div>
|
||
<div class="cw">
|
||
<canvas id="lc" role="img" aria-label="Monthly sales trend for selected doctor"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||
<script>
|
||
let doctors = [], allMonths = [], selMonth = '', selIdx = -1, chartInst = null, activeKpi = '';
|
||
let filters = { roi: [], avg: [], ps: [] };
|
||
let searchQ = '';
|
||
let doctorSources = [];
|
||
const allDataAccess = @json(!empty($allDataAccess));
|
||
const hierarchyNodes = @json($hierarchies ?? []);
|
||
const brandOptions = @json($brands ?? []);
|
||
const defaultBrandKey = @json($defaultBrand ?? 'pulmosamrt_group');
|
||
let selectedBrandKey = defaultBrandKey;
|
||
const hierarchyById = {};
|
||
let hierFilters = { zone: '', region: '', area: '', territory: '' };
|
||
|
||
hierarchyNodes.forEach((node) => {
|
||
if (node && node.id) {
|
||
hierarchyById[node.id] = node;
|
||
}
|
||
});
|
||
|
||
const getSelectedBrand = () => brandOptions.find((b) => b.key === selectedBrandKey) || brandOptions[0] || null;
|
||
const getBrandLabel = () => (getSelectedBrand()?.label || 'Brand');
|
||
|
||
const fmt = n => (n || n === 0) ? '₹' + Math.round(n).toLocaleString('en-IN') : '—';
|
||
const fmtR = n => (n === null || n === undefined) ? '—' : n.toFixed(1) + '×';
|
||
const fmtP = n => (n || n === 0) ? n.toFixed(1) + '%' : '—';
|
||
|
||
const opLabel = (op, val, type) => {
|
||
if (type === 'avg' && op === 'lt') return 'below avg';
|
||
if (type === 'avg' && op === 'gt') return 'above avg';
|
||
if (type === 'avg' && op === 'pct-lt') return `sel < ${val}% of avg`;
|
||
const sym = { lt: '<', gt: '>', eq: '=' };
|
||
return `${sym[op] || op} ${val}`;
|
||
};
|
||
|
||
const normalizeParents = (node) => {
|
||
if (!node || node.parents === undefined || node.parents === null) return [];
|
||
if (Array.isArray(node.parents)) return node.parents.filter(Boolean);
|
||
if (typeof node.parents === 'string') return node.parents.split('#').filter(Boolean);
|
||
return [];
|
||
};
|
||
|
||
const isUnderHierarchy = (node, ancestorId) => {
|
||
if (!node || !ancestorId) return false;
|
||
if (node.id === ancestorId || node.parent_id === ancestorId) return true;
|
||
return normalizeParents(node).includes(ancestorId);
|
||
};
|
||
|
||
const sortByName = (items) => items.slice().sort((a, b) => String(a.name || '').localeCompare(String(b.name || '')));
|
||
|
||
const getHierarchyOptions = (type, parentId) => {
|
||
if (!parentId) return [];
|
||
return sortByName(hierarchyNodes.filter((node) => node.type === type && isUnderHierarchy(node, parentId)));
|
||
};
|
||
|
||
const getActiveHierarchyFilter = () => {
|
||
if (hierFilters.territory) return { type: 'Territory', id: hierFilters.territory };
|
||
if (hierFilters.area) return { type: 'Area', id: hierFilters.area };
|
||
if (hierFilters.region) return { type: 'Region', id: hierFilters.region };
|
||
if (hierFilters.zone) return { type: 'Zone', id: hierFilters.zone };
|
||
return null;
|
||
};
|
||
|
||
const hierarchyFieldMap = { Zone: 'zone', Region: 'region', Area: 'area', Territory: 'territory' };
|
||
|
||
function renderBrandDropdown() {
|
||
const el = document.getElementById('brand-filter');
|
||
if (!el) return;
|
||
el.innerHTML = brandOptions.map((brand) =>
|
||
`<option value="${brand.key}"${selectedBrandKey === brand.key ? ' selected' : ''}>${brand.label}</option>`
|
||
).join('');
|
||
}
|
||
|
||
function onBrandChange() {
|
||
const el = document.getElementById('brand-filter');
|
||
selectedBrandKey = el?.value || defaultBrandKey;
|
||
rebuildDoctorsFromSources();
|
||
renderKPIs();
|
||
renderTable();
|
||
updateFilterDesc();
|
||
if (selIdx >= 0) renderChart(selIdx);
|
||
}
|
||
|
||
function renderHierarchyDropdowns() {
|
||
if (!allDataAccess) return;
|
||
|
||
const zoneEl = document.getElementById('hier-zone');
|
||
const regionEl = document.getElementById('hier-region');
|
||
const areaEl = document.getElementById('hier-area');
|
||
const territoryEl = document.getElementById('hier-territory');
|
||
if (!zoneEl || !regionEl || !areaEl || !territoryEl) return;
|
||
|
||
const zones = sortByName(hierarchyNodes.filter((node) => node.type === 'Zone'));
|
||
zoneEl.innerHTML = `<option value="">All</option>${zones.map((node) => `<option value="${node.id}"${hierFilters.zone === node.id ? ' selected' : ''}>${node.name || node.code || node.id}</option>`).join('')}`;
|
||
|
||
const regions = hierFilters.zone ? getHierarchyOptions('Region', hierFilters.zone) : [];
|
||
regionEl.innerHTML = `<option value="">All</option>${regions.map((node) => `<option value="${node.id}"${hierFilters.region === node.id ? ' selected' : ''}>${node.name || node.code || node.id}</option>`).join('')}`;
|
||
regionEl.disabled = !hierFilters.zone;
|
||
|
||
const areas = hierFilters.region ? getHierarchyOptions('Area', hierFilters.region) : [];
|
||
areaEl.innerHTML = `<option value="">All</option>${areas.map((node) => `<option value="${node.id}"${hierFilters.area === node.id ? ' selected' : ''}>${node.name || node.code || node.id}</option>`).join('')}`;
|
||
areaEl.disabled = !hierFilters.region;
|
||
|
||
const territories = hierFilters.area ? getHierarchyOptions('Territory', hierFilters.area) : [];
|
||
territoryEl.innerHTML = `<option value="">All</option>${territories.map((node) => `<option value="${node.id}"${hierFilters.territory === node.id ? ' selected' : ''}>${node.name || node.code || node.id}</option>`).join('')}`;
|
||
territoryEl.disabled = !hierFilters.area;
|
||
}
|
||
|
||
function onHierChange(level) {
|
||
const map = {
|
||
zone: 'hier-zone',
|
||
region: 'hier-region',
|
||
area: 'hier-area',
|
||
territory: 'hier-territory',
|
||
};
|
||
hierFilters[level] = document.getElementById(map[level])?.value || '';
|
||
|
||
if (level === 'zone') {
|
||
hierFilters.region = '';
|
||
hierFilters.area = '';
|
||
hierFilters.territory = '';
|
||
} else if (level === 'region') {
|
||
hierFilters.area = '';
|
||
hierFilters.territory = '';
|
||
} else if (level === 'area') {
|
||
hierFilters.territory = '';
|
||
}
|
||
|
||
renderHierarchyDropdowns();
|
||
renderTable();
|
||
renderKPIs();
|
||
updateFilterDesc();
|
||
}
|
||
|
||
function passesHierarchyFilter(d) {
|
||
const active = getActiveHierarchyFilter();
|
||
if (!active) return true;
|
||
const node = hierarchyById[active.id];
|
||
if (!node) return true;
|
||
const field = hierarchyFieldMap[active.type];
|
||
return (d[field] || '') === (node.name || '');
|
||
}
|
||
|
||
function handleFile(e) {
|
||
const f = e.target.files[0]; if (!f) return;
|
||
const r = new FileReader();
|
||
r.onload = ev => {
|
||
const wb = XLSX.read(ev.target.result, { type: 'binary' });
|
||
parseData(XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], { header: 1, defval: '' }));
|
||
};
|
||
r.readAsBinaryString(f);
|
||
}
|
||
|
||
function parseData(rows) {
|
||
let hr = -1;
|
||
for (let i = 0; i < rows.length; i++) {
|
||
if (/sr\s*no/i.test(String(rows[i][0] || '')) || /doctor/i.test(String(rows[i][5] || ''))) { hr = i; break; }
|
||
}
|
||
if (hr < 0) { alert('Header row not found. Ensure the file follows PBS tracker format.'); return; }
|
||
const hdrs = rows[hr].map(h => String(h || '').trim());
|
||
const mMap = {}, mOrd = [];
|
||
hdrs.forEach((h, ci) => {
|
||
const t = h.match(/^([A-Za-z]+ \d{4})\s+Total/i);
|
||
const p = h.match(/^([A-Za-z]+ \d{4})\s+Pulmo/i);
|
||
const pct = h.match(/^([A-Za-z]+ \d{4})\s+%/i);
|
||
if (t) { const m = t[1]; if (!mMap[m]) { mMap[m] = {}; mOrd.push(m); } mMap[m].total = ci; }
|
||
if (p) { const m = p[1]; if (!mMap[m]) mMap[m] = {}; mMap[m].pulmo = ci; }
|
||
if (pct) { const m = pct[1]; if (!mMap[m]) mMap[m] = {}; mMap[m].pct = ci; }
|
||
});
|
||
const ci = {
|
||
sr: hdrs.findIndex(h => /sr\s*no/i.test(h)),
|
||
dr: hdrs.findIndex(h => /doctor\s*name/i.test(h)),
|
||
hq: hdrs.findIndex(h => /^hq$/i.test(h)),
|
||
pbc: hdrs.findIndex(h => /pbc\s*amt/i.test(h)),
|
||
};
|
||
allMonths = mOrd; doctors = [];
|
||
for (let r = hr + 1; r < rows.length; r++) {
|
||
const row = rows[r];
|
||
const dr = String(row[ci.dr] || '').trim(); if (!dr) continue;
|
||
const pbc = parseFloat(row[ci.pbc]) || 0;
|
||
const months = {};
|
||
mOrd.forEach(m => { months[m] = { total: parseFloat(row[mMap[m]?.total]) || 0, pulmo: parseFloat(row[mMap[m]?.pulmo]) || 0, pct: parseFloat(row[mMap[m]?.pct]) || 0 }; });
|
||
buildDoctor({ sr: row[ci.sr], dr, hq: row[ci.hq] || '', pbc, months });
|
||
}
|
||
init();
|
||
}
|
||
|
||
function buildDoctor(d) {
|
||
const mo = d.months;
|
||
const mwd = allMonths.filter(m => mo[m].total > 0);
|
||
const cumul = d.cumul != null ? d.cumul : allMonths.reduce((s, m) => s + (mo[m].total || 0), 0);
|
||
const cumulPulmo = d.cumulPulmo != null ? d.cumulPulmo : allMonths.reduce((s, m) => s + (mo[m].pulmo || 0), 0);
|
||
const avg = d.avg != null ? d.avg : (mwd.length ? cumul / mwd.length : 0);
|
||
const lastM = mwd.length ? mwd[mwd.length - 1] : null;
|
||
const roi = d.pbc > 0 ? cumul / d.pbc : null;
|
||
doctors.push({ ...d, mwd, cumul, cumulPulmo, avg, lastM, roi });
|
||
}
|
||
|
||
function getSelData(d) {
|
||
const mo = selMonth || d.lastM || '';
|
||
const total = d.months[mo]?.total || 0;
|
||
const pct = d.months[mo]?.pct || 0;
|
||
const mi = allMonths.indexOf(mo);
|
||
const prev = mi > 0 ? (d.months[allMonths[mi - 1]]?.total || 0) : null;
|
||
const trend = prev !== null ? total - prev : null;
|
||
const flagROI = d.roi !== null && d.roi < 5;
|
||
const flagAvg = total < d.avg && d.avg > 0;
|
||
const flagPS = pct > 0 && pct < 70;
|
||
return { total, pct, trend, flagROI, flagAvg, flagPS };
|
||
}
|
||
|
||
function passesFilters(d, skipKpi = false) {
|
||
const s = getSelData(d);
|
||
for (const f of filters.roi) {
|
||
if (d.roi === null) return false;
|
||
if (f.op === 'lt' && !(d.roi < f.val)) return false;
|
||
if (f.op === 'gt' && !(d.roi > f.val)) return false;
|
||
if (f.op === 'eq' && !(Math.abs(d.roi - f.val) < 0.05)) return false;
|
||
}
|
||
for (const f of filters.avg) {
|
||
if (f.op === 'lt' && !s.flagAvg) return false;
|
||
if (f.op === 'gt' && s.flagAvg) return false;
|
||
if (f.op === 'pct-lt') { const pct = f.val / 100; if (!(s.total < d.avg * pct)) return false; }
|
||
}
|
||
for (const f of filters.ps) {
|
||
if (f.op === 'lt' && !(s.pct > 0 && s.pct < f.val)) return false;
|
||
if (f.op === 'gt' && !(s.pct > f.val)) return false;
|
||
}
|
||
if (searchQ) {
|
||
const q = searchQ.toLowerCase();
|
||
if (!d.dr.toLowerCase().includes(q) && !(d.hq || '').toLowerCase().includes(q)) return false;
|
||
}
|
||
if (!passesHierarchyFilter(d)) return false;
|
||
if (!skipKpi) {
|
||
if (activeKpi === 'roi' && !s.flagROI) return false;
|
||
if (activeKpi === 'avg' && !s.flagAvg) return false;
|
||
if (activeKpi === 'ps' && !s.flagPS) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function addFilter(type) {
|
||
const op = document.getElementById(type + '-op').value;
|
||
const rawVal = document.getElementById(type + '-val').value;
|
||
const val = (type === 'avg' && (op === 'lt' || op === 'gt')) ? null : parseFloat(rawVal);
|
||
if ((type !== 'avg' || op === 'pct-lt') && (rawVal === '' || isNaN(val))) return;
|
||
if (type === 'avg' && op === 'pct-lt' && (rawVal === '' || isNaN(val))) return;
|
||
const label = opLabel(op, val, type);
|
||
if (type === 'avg' && (op === 'lt' || op === 'gt')) {
|
||
if (filters.avg.some(f => f.op === op)) return;
|
||
}
|
||
filters[type].push({ op, val, label });
|
||
renderChips(); renderTable(); renderKPIs(); updateFilterDesc();
|
||
}
|
||
|
||
function removeFilter(type, idx) {
|
||
filters[type].splice(idx, 1);
|
||
renderChips(); renderTable(); renderKPIs(); updateFilterDesc();
|
||
}
|
||
|
||
function renderChips() {
|
||
['roi', 'avg', 'ps'].forEach(type => {
|
||
document.getElementById('chips-' + type).innerHTML = filters[type].map((f, i) =>
|
||
`<span class="chip chip-${type}">${f.label}<span class="chip-rm" onclick="removeFilter('${type}',${i})" title="Remove">×</span></span>`
|
||
).join('');
|
||
});
|
||
}
|
||
|
||
function setSearch(v) {
|
||
searchQ = v;
|
||
const el = document.getElementById('chips-srch');
|
||
el.innerHTML = v ? `<span class="chip chip-srch">"${v}"<span class="chip-rm" onclick="clearSearch()" title="Clear">×</span></span>` : '';
|
||
renderTable(); renderKPIs(); updateFilterDesc();
|
||
}
|
||
|
||
function clearSearch() {
|
||
searchQ = '';
|
||
document.getElementById('srchIn').value = '';
|
||
document.getElementById('chips-srch').innerHTML = '';
|
||
renderTable(); renderKPIs(); updateFilterDesc();
|
||
}
|
||
|
||
function toggleFilterSection() {
|
||
const section = document.getElementById('filterSection');
|
||
const isOpen = section.classList.toggle('open');
|
||
const hdr = section.querySelector('.filter-section-hdr');
|
||
if (hdr) hdr.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||
}
|
||
|
||
function clearAllFilters() {
|
||
filters = { roi: [], avg: [], ps: [] };
|
||
activeKpi = ''; searchQ = '';
|
||
hierFilters = { zone: '', region: '', area: '', territory: '' };
|
||
selectedBrandKey = defaultBrandKey;
|
||
document.getElementById('srchIn').value = '';
|
||
renderChips();
|
||
document.getElementById('chips-srch').innerHTML = '';
|
||
renderBrandDropdown();
|
||
renderHierarchyDropdowns();
|
||
rebuildDoctorsFromSources();
|
||
renderKPIs(); renderTable(); updateFilterDesc();
|
||
if (selIdx >= 0) renderChart(selIdx);
|
||
}
|
||
|
||
function updateFilterDesc() {
|
||
const parts = [];
|
||
const brand = getSelectedBrand();
|
||
if (brand) parts.push(`Brand: ${brand.label}`);
|
||
filters.roi.forEach(f => parts.push(`ROI ${f.label}`));
|
||
filters.avg.forEach(f => parts.push(`Sales ${f.label}`));
|
||
filters.ps.forEach(f => parts.push(`Brand% ${f.label}`));
|
||
if (searchQ) parts.push(`search "${searchQ}"`);
|
||
const activeHier = getActiveHierarchyFilter();
|
||
if (activeHier) {
|
||
const node = hierarchyById[activeHier.id];
|
||
if (node) parts.push(`${activeHier.type}: ${node.name || node.code || activeHier.id}`);
|
||
}
|
||
if (activeKpi) parts.push(`KPI: ${activeKpi}`);
|
||
const el = document.getElementById('activeFilterDesc');
|
||
el.textContent = parts.length ? 'Active: ' + parts.join(' · ') : 'No filters applied — showing all doctors';
|
||
}
|
||
|
||
function clickKpi(kpi) {
|
||
activeKpi = activeKpi === kpi ? '' : kpi;
|
||
renderKPIs(); renderTable(); updateFilterDesc();
|
||
}
|
||
|
||
function init() {
|
||
selMonth = allMonths[allMonths.length - 1] || '';
|
||
filters = { roi: [], avg: [], ps: [] };
|
||
searchQ = ''; activeKpi = '';
|
||
hierFilters = { zone: '', region: '', area: '', territory: '' };
|
||
selectedBrandKey = defaultBrandKey;
|
||
renderBrandDropdown();
|
||
renderHierarchyDropdowns();
|
||
if (doctorSources.length) {
|
||
rebuildDoctorsFromSources();
|
||
}
|
||
renderMonthTabs(); renderKPIs(); renderTable(); renderChips(); updateFilterDesc();
|
||
}
|
||
|
||
function renderMonthTabs() {
|
||
document.getElementById('monthTabs').innerHTML = allMonths.map(m =>
|
||
`<button class="mtab${m === selMonth ? ' active' : ''}" onclick="setMonth('${m}')">${m}</button>`
|
||
).join('');
|
||
}
|
||
|
||
function setMonth(m) {
|
||
selMonth = m;
|
||
renderMonthTabs(); renderKPIs(); renderTable();
|
||
if (selIdx >= 0) renderChart(selIdx);
|
||
}
|
||
|
||
function renderKPIs() {
|
||
if (!doctors.length) return;
|
||
const filtered = doctors.filter((d) => passesFilters(d, true));
|
||
const allS = filtered.map((d) => getSelData(d));
|
||
const cntROI = allS.filter((s) => s.flagROI).length;
|
||
const cntAvg = allS.filter((s) => s.flagAvg).length;
|
||
const cntPS = allS.filter((s) => s.flagPS).length;
|
||
const cntAny = allS.filter((s) => s.flagROI || s.flagAvg || s.flagPS).length;
|
||
const mn = selMonth.split(' ')[0] || 'sel';
|
||
const kpis = [
|
||
{ id: 'total', label: 'Total doctors', val: filtered.length, cls: '', hint: 'click to show all', kpi: '' },
|
||
{ id: 'roi', label: 'ROI < 5x spend', val: cntROI, cls: 'danger', hint: 'click to filter', kpi: 'roi' },
|
||
{ id: 'avg', label: `Below avg (${mn})`, val: cntAvg, cls: 'warn', hint: 'click to filter', kpi: 'avg' },
|
||
{ id: 'ps', label: `Brand% < 70% (${mn})`, val: cntPS, cls: 'safe', hint: 'click to filter', kpi: 'ps' },
|
||
{ id: 'any', label: 'Any flag raised', val: cntAny, cls: 'danger', hint: '', kpi: '' },
|
||
];
|
||
document.getElementById('kpiRow').innerHTML = kpis.map(k =>
|
||
`<div class="kpi${activeKpi === k.kpi && k.kpi ? ' kpi-active' : ''}"
|
||
onclick="${k.kpi ? `clickKpi('${k.kpi}')` : 'clearAllFilters()'}"
|
||
title="${k.hint}" role="button" tabindex="0"
|
||
onkeydown="if(event.key==='Enter'||event.key===' '){${k.kpi ? `clickKpi('${k.kpi}')` : 'clearAllFilters()'}}">
|
||
<div class="kpi-label">${k.label}</div>
|
||
<div class="kpi-val ${k.cls}">${k.val}</div>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
|
||
function trendIcon(trend) {
|
||
if (trend === null) return '<span class="trend-flat">—</span>';
|
||
if (trend > 0) return '<i class="ti ti-arrow-up trend-up" aria-label="gaining"></i>';
|
||
if (trend < 0) return '<i class="ti ti-arrow-down trend-down" aria-label="falling"></i>';
|
||
return '<i class="ti ti-minus trend-flat" aria-label="no change"></i>';
|
||
}
|
||
|
||
function renderTable() {
|
||
const list = doctors.filter(d => passesFilters(d));
|
||
const tb = document.getElementById('tb');
|
||
const noData = document.getElementById('noData');
|
||
noData.style.display = list.length ? 'none' : 'block';
|
||
if (!list.length) { tb.innerHTML = ''; return; }
|
||
tb.innerHTML = list.map((d, i) => {
|
||
const isS = doctors.indexOf(d) === selIdx;
|
||
const s = getSelData(d);
|
||
const roiC = d.roi === null ? '' : (d.roi < 5 ? 'style="color:#e24b4a;font-weight:600"' : 'style="color:#3b6d11"');
|
||
const selC = s.flagAvg ? 'style="color:#ba7517;font-weight:600"' : '';
|
||
const psC = s.flagPS ? 'style="color:#ba7517;font-weight:600"' : (s.pct >= 70 ? 'style="color:#3b6d11"' : '');
|
||
const badges = [
|
||
s.flagROI ? '<span class="badge b-roi"><i class="ti ti-alert-triangle"></i>ROI < 5×</span>' : '',
|
||
s.flagAvg ? '<span class="badge b-avg"><i class="ti ti-trending-down"></i>Below avg</span>' : '',
|
||
s.flagPS ? '<span class="badge b-ps"><i class="ti ti-leaf"></i>Brand% < 70%</span>' : '',
|
||
].join('');
|
||
return `<tr class="${isS ? 'sel' : ''}" onclick="selectDoc(${doctors.indexOf(d)})">
|
||
<td class="tc col-freeze col-freeze-doctor"><span class="dr">${d.dr}</span></td>
|
||
<td class="tc">${d.zone || '—'}</td>
|
||
<td class="tc">${d.region || '—'}</td>
|
||
<td class="tc">${d.area || '—'}</td>
|
||
<td class="tc" style="">${d.hq}</td>
|
||
<td class="tc">${fmt(d.pbc)}</td>
|
||
<td class="tc">${fmt(d.cumul)}</td>
|
||
<td class="tc" ${roiC}>${fmtR(d.roi)}</td>
|
||
<td class="tc">${fmt(d.avg)}</td>
|
||
<td class="tc" ${selC}>${fmt(s.total)}</td>
|
||
<td class="tc" ${psC}>${fmtP(s.pct)}</td>
|
||
<td><div class="flags-cell">${badges || '<span style="color:var(--text-muted);font-size:11px">—</span>'}</div></td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function selectDoc(idx) {
|
||
selIdx = idx;
|
||
renderTable();
|
||
renderChart(idx);
|
||
bootstrap.Modal.getOrCreateInstance(document.getElementById('doctorModal')).show();
|
||
}
|
||
|
||
function renderChart(idx) {
|
||
const d = doctors[idx];
|
||
const brandLabel = getBrandLabel();
|
||
document.getElementById('doctorModalLabel').textContent = d.dr + ' — monthly sales trend';
|
||
document.getElementById('csub').textContent = `HQ: ${d.hq} · PBC spend: ${fmt(d.pbc)} · ROI: ${fmtR(d.roi)} · Avg monthly: ${fmt(d.avg)} · Brand: ${brandLabel}`;
|
||
|
||
const months = allMonths.filter(m => allMonths.indexOf(m) <= allMonths.indexOf(d.lastM || allMonths[allMonths.length - 1]));
|
||
const totals = months.map(m => d.months[m]?.total || 0);
|
||
const pulmos = months.map(m => d.months[m]?.pulmo || 0);
|
||
const pcts = months.map(m => d.months[m]?.pct || 0);
|
||
const avgLine = months.map(() => Math.round(d.avg));
|
||
const selMi = months.indexOf(selMonth);
|
||
const ptBg = months.map((_, i) => i === selMi ? '#6658dd' : '#6658dd');
|
||
const ptR = months.map((_, i) => i === selMi ? 8 : 5);
|
||
const s = getSelData(d);
|
||
|
||
document.getElementById('cleg').innerHTML = `
|
||
<span class="cli"><span class="cls" style="background:#6658dd"></span>Total sales</span>
|
||
<span class="cli"><span class="cls" style="background:#1baf7a"></span>${brandLabel}</span>
|
||
<span class="cli"><span class="cls" style="background:#eda100"></span>Brand%</span>
|
||
<span class="cli"><span style="width:18px;height:2px;border-top:2px dashed #888780;display:inline-block;vertical-align:middle"></span>Avg</span>
|
||
${selMonth && selMi >= 0 ? `<span class="cli" style="background:var(--accent-bg);color:var(--accent-text);padding:1px 8px;border-radius:4px;font-size:11px;border:0.5px solid var(--accent-border)"><i class="ti ti-pin"></i> ${selMonth}</span>` : ''}
|
||
`;
|
||
|
||
const minis = document.getElementById('cminis');
|
||
if (selMonth && selMi >= 0) {
|
||
minis.innerHTML = `
|
||
<div class="mini-kpi">
|
||
<div class="kpi-label">${selMonth.split(' ')[0]} sales</div>
|
||
<div class="kpi-val">${fmt(s.total)}</div>
|
||
<div style="margin-top:3px;font-size:13px">${s.trend === null ? '' : (s.trend > 0 ? '<i class="ti ti-arrow-up" style="color:#3b6d11"></i>' : (s.trend < 0 ? '<i class="ti ti-arrow-down" style="color:#e24b4a"></i>' : ''))}</div>
|
||
</div>
|
||
<div class="mini-kpi">
|
||
<div class="kpi-label">Brand%</div>
|
||
<div class="kpi-val" style="color:${s.pct < 70 ? '#ba7517' : '#3b6d11'}">${fmtP(s.pct)}</div>
|
||
</div>`;
|
||
} else { minis.innerHTML = ''; }
|
||
|
||
if (chartInst) chartInst.destroy();
|
||
const ctx = document.getElementById('lc').getContext('2d');
|
||
chartInst = new Chart(ctx, {
|
||
data: {
|
||
labels: months,
|
||
datasets: [
|
||
{ type: 'line', label: 'Total sales', data: totals, borderColor: '#6658dd', backgroundColor: 'rgba(42,120,214,.07)', borderWidth: 2, pointRadius: ptR, pointBackgroundColor: ptBg, pointBorderColor: '#fff', pointBorderWidth: 1.5, tension: .3, yAxisID: 'y', fill: true },
|
||
{ type: 'line', label: 'Avg', data: avgLine, borderColor: '#888780', backgroundColor: 'transparent', borderWidth: 1.5, borderDash: [5, 4], pointRadius: 0, yAxisID: 'y' },
|
||
{ type: 'line', label: brandLabel, data: pulmos, borderColor: '#1baf7a', backgroundColor: 'rgba(27,175,122,.05)', borderWidth: 2, pointRadius: 4, pointBackgroundColor: '#1baf7a', tension: .3, yAxisID: 'y', borderDash: [4, 3] },
|
||
{ type: 'line', label: 'Brand%', data: pcts, borderColor: '#eda100', backgroundColor: 'transparent', borderWidth: 1.5, pointRadius: 3, pointBackgroundColor: '#eda100', tension: .3, yAxisID: 'yP', borderDash: [2, 4] },
|
||
]
|
||
},
|
||
options: {
|
||
responsive: true, maintainAspectRatio: false,
|
||
interaction: { mode: 'index', intersect: false },
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: { callbacks: { label: c => {
|
||
if (c.dataset.yAxisID === 'yP') return c.dataset.label + ': ' + c.parsed.y.toFixed(1) + '%';
|
||
return c.dataset.label + ': ₹' + Math.round(c.parsed.y).toLocaleString('en-IN');
|
||
}}},
|
||
},
|
||
scales: {
|
||
x: { grid: { color: 'rgba(0,0,0,.04)' }, ticks: { font: { size: 11 }, color: '#898781', autoSkip: false, maxRotation: 40 } },
|
||
y: { position: 'left', grid: { color: 'rgba(0,0,0,.04)' }, ticks: { font: { size: 11 }, color: '#898781', callback: v => '₹' + Math.round(v / 1000) + 'K' } },
|
||
yP: { position: 'right', min: 0, max: 100, grid: { display: false }, ticks: { font: { size: 11 }, color: '#eda100', callback: v => v + '%' } },
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
function rebuildDoctorsFromSources() {
|
||
doctors = [];
|
||
const brand = getSelectedBrand();
|
||
const brandMetricId = brand?.id || '';
|
||
const useStoredPs = brand?.key === defaultBrandKey;
|
||
|
||
doctorSources.forEach((source) => {
|
||
const monthValues = {};
|
||
let cumulPulmo = 0;
|
||
|
||
allMonths.forEach((monthLabel) => {
|
||
const row = source.months[monthLabel] || { total: 0, brands: {}, pctStored: 0 };
|
||
const total = Number(row.total) || 0;
|
||
const pulmo = Number(row.brands?.[brandMetricId]) || 0;
|
||
let pct = 0;
|
||
if (useStoredPs && row.pctStored) {
|
||
pct = Number(row.pctStored) || 0;
|
||
} else if (total > 0) {
|
||
pct = (pulmo / total) * 100;
|
||
}
|
||
monthValues[monthLabel] = { total, pulmo, pct };
|
||
cumulPulmo += pulmo;
|
||
});
|
||
|
||
buildDoctor({
|
||
sr: source.sr,
|
||
dr: source.dr,
|
||
hq: source.hq,
|
||
zone: source.zone,
|
||
region: source.region,
|
||
area: source.area,
|
||
territory: source.territory,
|
||
pbc: source.pbc,
|
||
cumul: source.cumul,
|
||
cumulPulmo,
|
||
avg: source.avg,
|
||
months: monthValues,
|
||
});
|
||
});
|
||
}
|
||
|
||
(function loadReportData() {
|
||
const report = {
|
||
months: @json($months),
|
||
doctors: @json($doctors),
|
||
data: @json($data),
|
||
metricIds: @json($metricIds),
|
||
};
|
||
|
||
const months = report.months || [];
|
||
const reportDoctors = report.doctors || [];
|
||
const data = report.data || {};
|
||
const metricIds = report.metricIds || {};
|
||
const PBC_ID = metricIds.pbc;
|
||
const TOTAL_ID = metricIds.total;
|
||
const PS_ID = metricIds.pct;
|
||
const brandMetricIds = brandOptions.map((b) => b.id).filter(Boolean);
|
||
|
||
allMonths = months.map((month) => {
|
||
const year = String(month.key || '').split('-')[0] || '';
|
||
return `${month.label} ${year}`.trim();
|
||
});
|
||
|
||
doctorSources = [];
|
||
reportDoctors.forEach((doctor, index) => {
|
||
const doctorData = data[doctor.id] || {};
|
||
let pbc = doctor.pbc_spend != null ? Number(doctor.pbc_spend) || 0 : 0;
|
||
const monthValues = {};
|
||
|
||
let cumul = 0;
|
||
let allTimeMonthsWithData = 0;
|
||
Object.keys(doctorData).forEach((monthKey) => {
|
||
const monthMetrics = doctorData[monthKey] || {};
|
||
const total = Number(monthMetrics[TOTAL_ID]) || 0;
|
||
cumul += total;
|
||
if (total > 0) {
|
||
allTimeMonthsWithData += 1;
|
||
}
|
||
});
|
||
|
||
months.forEach((month, monthIndex) => {
|
||
const monthLabel = allMonths[monthIndex];
|
||
const monthMetrics = doctorData[month.key] || {};
|
||
const total = Number(monthMetrics[TOTAL_ID]) || 0;
|
||
const brands = {};
|
||
brandMetricIds.forEach((id) => {
|
||
brands[id] = Number(monthMetrics[id]) || 0;
|
||
});
|
||
monthValues[monthLabel] = {
|
||
total,
|
||
brands,
|
||
pctStored: Number(monthMetrics[PS_ID]) || 0,
|
||
};
|
||
});
|
||
|
||
if (doctor.pbc_spend == null) {
|
||
const allMonthKeys = Object.keys(doctorData).sort();
|
||
for (let i = allMonthKeys.length - 1; i >= 0; i--) {
|
||
const monthMetrics = doctorData[allMonthKeys[i]] || {};
|
||
if (monthMetrics[PBC_ID] !== undefined && monthMetrics[PBC_ID] !== null) {
|
||
pbc = Number(monthMetrics[PBC_ID]) || 0;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
doctorSources.push({
|
||
sr: index + 1,
|
||
dr: doctor.name || '',
|
||
hq: doctor.hq || '',
|
||
zone: doctor.zone || '',
|
||
region: doctor.region || '',
|
||
area: doctor.area || '',
|
||
territory: doctor.territory || '',
|
||
pbc,
|
||
cumul,
|
||
avg: allTimeMonthsWithData ? cumul / allTimeMonthsWithData : 0,
|
||
months: monthValues,
|
||
});
|
||
});
|
||
|
||
init();
|
||
})();
|
||
|
||
document.getElementById('doctorModal').addEventListener('shown.bs.modal', () => {
|
||
if (chartInst) chartInst.resize();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|