Rapport-Laravel/resources/views/pbsdoctoranalyticsreport/report.blade.php

989 lines
45 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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 &lt; 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 &lt; 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% &lt; 70%</span>
<span class="tag-info">
<i class="ti ti-arrow-up" style="color:#3b6d11"></i>gaining &nbsp;
<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 &lt; 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% &lt; 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>