added 4 domain custom reports and data designer changes

This commit is contained in:
Abhijit 2026-11-02 10:22:26 +05:30
parent 22c4391380
commit db593f9a6c
28 changed files with 3623 additions and 105 deletions

View File

@ -97,3 +97,7 @@
@import "pages/dashboard"; @import "pages/dashboard";
@import "pages/rcpa"; @import "pages/rcpa";
@import "pages/gennovadc"; @import "pages/gennovadc";
@import "pages/zenova";
@import "pages/coaching";
@import "pages/adherence";
@import "pages/iva";

View File

@ -0,0 +1,57 @@
.pob-table-wrap {
overflow: auto;
max-height: 70vh;
}
.pob-table {
--#{$prefix}table-border-color: #c4c9d0;
font-size: 12px;
white-space: nowrap;
th,
td {
vertical-align: middle;
text-align: center;
border-color: #c4c9d0;
}
thead tr:nth-child(1) th {
position: sticky;
top: 0;
z-index: 3;
background: #fff;
}
thead tr:nth-child(2) th {
position: sticky;
top: 28px;
z-index: 2;
background: #fff;
}
thead tr th.pob-day-w {
background: #c6efce;
color: #000;
}
td.pob-day-h,
thead tr th.pob-day-h {
background: #d9d9d9;
color: #000;
}
thead tr th.pob-identity {
background: #c00000;
color: #fff;
}
thead tr th.pob-summary {
background: #8a1538;
color: #fff;
}
tr.pob-vacant td {
background: #f8cbad;
color: #000;
}
}

View File

@ -0,0 +1,73 @@
.coaching-scroll {
overflow-x: auto;
}
.coaching-loading {
position: absolute;
inset: 0;
z-index: 6;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--#{$prefix}secondary-bg) 70%, transparent);
}
.coaching-table {
--#{$prefix}table-border-color: #c4c9d0;
width: max-content;
min-width: 100%;
border-color: #c4c9d0;
> :not(caption) > * {
border-color: #c4c9d0;
}
th,
td {
white-space: nowrap;
vertical-align: middle;
border-color: #c4c9d0 !important;
}
th {
color: #5c6470;
}
td {
color: #6c757d;
}
.sticky-col {
position: sticky;
left: 0;
width: 1%;
white-space: nowrap;
z-index: 2;
background-color: #fff;
}
thead .sticky-col {
z-index: 3;
}
}
.coaching-action {
th,
td {
text-align: center;
}
th.sticky-col,
td.sticky-col {
text-align: left;
box-shadow: none !important;
}
thead .sticky-col {
background-color: #fff !important;
}
tbody td.sticky-col {
background-color: rgba(var(--#{$prefix}light-rgb), 0.45) !important;
}
}

View File

@ -0,0 +1,129 @@
.iva-report-scroll {
overflow-x: auto;
width: 100%;
}
.iva-loading {
position: absolute;
inset: 0;
z-index: 6;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--#{$prefix}secondary-bg) 70%, transparent);
}
.iva-filter-toggle {
width: 220px;
text-align: left;
&::after {
display: none;
}
}
.iva-filter-menu {
min-width: 280px;
max-height: 340px;
}
.iva-filter-options {
max-height: 240px;
overflow-y: auto;
}
.iva-report-table {
border-collapse: separate;
border-spacing: 0;
width: 100%;
font-size: 13px;
th,
td {
border: 1px solid #b0b0b0;
vertical-align: middle;
white-space: nowrap;
text-align: center;
}
thead th {
font-weight: 700;
white-space: normal;
min-width: 88px;
}
th.sticky-first-col,
td.sticky-first-col {
position: sticky;
left: 0;
z-index: 2;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.08);
min-width: 140px;
}
thead th.sticky-first-col {
z-index: 4;
}
tbody td.sticky-first-col {
background-color: #fff;
}
.iva-group-start {
border-left: 2px solid #6c757d;
}
th.iva-sortable {
cursor: pointer;
position: relative;
padding-right: 1.4rem;
&::after {
content: '\2195';
position: absolute;
right: 0.4rem;
opacity: 0.35;
}
}
th.iva-sorted-asc::after {
content: '\2191';
opacity: 0.9;
}
th.iva-sorted-desc::after {
content: '\2193';
opacity: 0.9;
}
}
.iva-th-id {
background-color: #00b050 !important;
color: #000 !important;
}
.iva-th-metric {
background-color: #ffff00 !important;
color: #000 !important;
}
.iva-th-meta {
background-color: #e7e6e6 !important;
color: #000 !important;
}
.iva-planner-materna-hmg { background-color: #e7e6e6 !important; color: #000 !important; }
.iva-planner-ciscure { background-color: #f8cbad !important; color: #000 !important; }
.iva-planner-emprogest { background-color: #deebf7 !important; color: #000 !important; }
.iva-sku-ciscure { background-color: #ed7d31 !important; color: #fff !important; }
.iva-sku-dydroeva { background-color: #3d4c5f !important; color: #fff !important; }
.iva-sku-emgra { background-color: #b4c7e7 !important; color: #000 !important; }
.iva-sku-emprogest { background-color: #548235 !important; color: #fff !important; }
.iva-sku-exhep { background-color: #deebf7 !important; color: #000 !important; }
.iva-sku-materna-depot { background-color: #ffc000 !important; color: #000 !important; }
.iva-sku-materna-hcg { background-color: #ffe699 !important; color: #000 !important; }
.iva-sku-materna { background-color: #e2efda !important; color: #000 !important; }
.iva-sku-orofer { background-color: #bdd7ee !important; color: #000 !important; }
.iva-sku-silnafil { background-color: #b25e25 !important; color: #fff !important; }
.iva-sku-xgrast { background-color: #a9ce91 !important; color: #000 !important; }

View File

@ -0,0 +1,114 @@
.zenova-wrap {
overflow: auto;
max-height: calc(100vh - 220px);
}
.zenova-loading {
position: absolute;
inset: 0;
z-index: 7;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--#{$prefix}secondary-bg) 70%, transparent);
}
.zenova-table {
border-collapse: separate;
border-spacing: 0;
font-size: 12px;
min-width: 100%;
th,
td {
border: 1px solid #c4c9d0;
white-space: nowrap;
padding: 6px 8px;
background: #fff;
}
thead th {
position: sticky;
z-index: 2;
text-align: center;
font-weight: 600;
color: #5c6470;
}
td {
color: #6c757d;
}
thead tr:first-child th {
top: 0;
height: 56px;
}
thead tr:nth-child(2) th {
top: 56px;
}
thead tr:nth-child(2) th.sticky-zone,
tbody td.sticky-zone {
position: sticky;
left: 0;
min-width: 160px;
width: 160px;
background: #fff;
}
thead tr:nth-child(2) th.sticky-region,
tbody td.sticky-region {
position: sticky;
left: 160px;
min-width: 160px;
width: 160px;
background: #fff;
box-shadow: 2px 0 0 #c4c9d0;
}
tbody td.sticky-zone,
tbody td.sticky-region {
z-index: 1;
}
tr.totals td.sticky-zone,
tr.totals td.sticky-region {
background: #f8f9fa;
}
thead tr:first-child th.sticky-group {
position: sticky;
left: 0;
z-index: 6;
min-width: 320px;
white-space: normal;
box-shadow: 2px 0 0 #c4c9d0;
}
thead th.sticky-zone,
thead th.sticky-region {
z-index: 5;
}
td {
text-align: right;
}
td.text-cell,
th.text-cell {
text-align: left;
}
tr.totals td {
font-weight: 700;
background: #f8f9fa;
}
.tone-demo { background: #fff2cc !important; }
.tone-day { background: #ddebf7 !important; }
.tone-yesterday { background: #e2efda !important; }
.tone-today { background: #c6efce !important; }
.tone-mtd { background: #fce4d6 !important; }
.tone-ytd { background: #d9e2f3 !important; }
}

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { Controller } from 'react-hook-form'; import { Controller, useWatch } from 'react-hook-form';
import { Button, Dropdown, Form } from 'react-bootstrap'; import { Button, Dropdown, Form } from 'react-bootstrap';
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
@ -128,7 +128,7 @@ function DesignerRow({ row, onPatch, onRemove }) {
); );
} }
function DataDesignerEditor({ value, onChange, userAttributes = {} }) { function DataDesignerEditor({ value, onChange, userAttributes = {}, workflow, rolesInfo, personalized }) {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const formId = searchParams.get('id') || ''; const formId = searchParams.get('id') || '';
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -217,6 +217,17 @@ function DataDesignerEditor({ value, onChange, userAttributes = {} }) {
option('created_date', 'question', 'created_date', 'Submitted Date'), option('created_date', 'question', 'created_date', 'Submitted Date'),
option('updated_date', 'question', 'updated_date', 'Last Updated Date'), option('updated_date', 'question', 'updated_date', 'Last Updated Date'),
option('workflow_status', 'question', 'workflow_status', 'Workflow Status'), option('workflow_status', 'question', 'workflow_status', 'Workflow Status'),
...(workflow === 'yes' ? [
option('workflow_action_date', 'question', 'workflow_action_date', 'Workflow Last Action Date'),
option('workflow_last_action', 'question', 'workflow_last_action', 'Workflow Last Action'),
] : []),
...(rolesInfo === 'yes' ? [
option('roles', 'question', 'roles', 'Role'),
] : []),
...(personalized === 'yes' ? [
option('template_name', 'question', 'template_name', 'Template Name'),
option('generated_image_url', 'question', 'generated_image_url', 'Generated Image'),
] : []),
]; ];
const appendRows = (picked) => { const appendRows = (picked) => {
@ -322,12 +333,22 @@ function DataDesignerEditor({ value, onChange, userAttributes = {} }) {
} }
export function DataDesignerField({ field, control }) { export function DataDesignerField({ field, control }) {
const workflow = useWatch({ control, name: 'workflow' });
const rolesInfo = useWatch({ control, name: 'roles_info' });
const personalized = useWatch({ control, name: 'is_personalized' });
return ( return (
<Controller <Controller
name={field.key} name={field.key}
control={control} control={control}
render={({ field: rhf }) => ( render={({ field: rhf }) => (
<DataDesignerEditor value={rhf.value} onChange={rhf.onChange} userAttributes={field.userAttributes || {}} /> <DataDesignerEditor
value={rhf.value}
onChange={rhf.onChange}
userAttributes={field.userAttributes || {}}
workflow={workflow}
rolesInfo={rolesInfo}
personalized={personalized}
/>
)} )}
/> />
); );

View File

@ -102,8 +102,8 @@ const AppMenu = () => {
const [openMenuKey, setOpenMenuKey] = useState(null); const [openMenuKey, setOpenMenuKey] = useState(null);
const { permissions, roles, domain, user } = useAuth(); const { permissions, roles, domain, user } = useAuth();
const menuItems = useMemo( const menuItems = useMemo(
() => getMenuItems({ permissions, roles, domain, email: user?.email }), () => getMenuItems({ permissions, roles, domain, email: user?.email, designation: user?.designation }),
[permissions, roles, domain, user?.email], [permissions, roles, domain, user?.email, user?.designation],
); );
const scrollToActiveLink = () => { const scrollToActiveLink = () => {
const activeItem = document.querySelector('.side-nav-link.active'); const activeItem = document.querySelector('.side-nav-link.active');

View File

@ -24,7 +24,7 @@ const child = (key, label, url, permission, extra = {}) => ({
...extra, ...extra,
}); });
export function getMenuItems({ permissions = [], roles = [], domain = '', email = '' } = {}) { export function getMenuItems({ permissions = [], roles = [], domain = '', email = '', designation = '' } = {}) {
const items = [ const items = [
{ {
key: 'domain-title', key: 'domain-title',
@ -103,6 +103,14 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
icon: LuFileText, icon: LuFileText,
url: '/foracortchemistavailabilitys', url: '/foracortchemistavailabilitys',
}); });
if (permissions.includes('all_data_access') || ['HO', 'ADMIN'].includes(String(designation).trim().toUpperCase())) {
items.push({
key: 'adherence-pob-drive',
label: 'Adherence Pack POB Drive',
icon: LuFileText,
url: '/adherencepobdrivereport/report',
});
}
} }
if (domain === 'gennovadc') { if (domain === 'gennovadc') {
@ -114,6 +122,20 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
}); });
} }
if (domain === 'iva') {
items.push({
key: 'iva-dashboards',
label: 'Custom Dashboards',
icon: LuChartNoAxesCombined,
children: [
child('iva-planner', 'Planner Data', '/ivareport/planner'),
child('iva-kamwise', 'KAM wise details', '/ivareport/kamwise'),
child('iva-drbrandwise', 'Dr & Brand Wise details', '/ivareport/drbrandwise'),
child('iva-skuwise', 'SKU wise Rate', '/ivareport/skuwise'),
],
});
}
if (domain === 'zenova' && roles.some((role) => ZENOVA_DASHBOARD_ROLES.includes(role))) { if (domain === 'zenova' && roles.some((role) => ZENOVA_DASHBOARD_ROLES.includes(role))) {
items.push({ items.push({
key: 'glipiq-sitazit', key: 'glipiq-sitazit',

View File

@ -94,6 +94,11 @@ const RcpaTmWise = lazy(() => import('@/views/rcpareport/TmWise'));
const RcpaMonthWise = lazy(() => import('@/views/rcpareport/MonthWise')); const RcpaMonthWise = lazy(() => import('@/views/rcpareport/MonthWise'));
const RcpaFormWise = lazy(() => import('@/views/rcpareport/FormWise')); const RcpaFormWise = lazy(() => import('@/views/rcpareport/FormWise'));
const GennovadcDashboard = lazy(() => import('@/views/gennovadcreport/Dashboard')); const GennovadcDashboard = lazy(() => import('@/views/gennovadcreport/Dashboard'));
const ZenovaDashboard = lazy(() => import('@/views/zenovareport/Dashboard'));
const ActionPlanReport = lazy(() => import('@/views/coachingtooldashboard/ActionPlanReport'));
const GoalProficiencyReport = lazy(() => import('@/views/coachingtooldashboard/GoalProficiencyReport'));
const AdherencePobDriveReport = lazy(() => import('@/views/adherencepobdrivereport/Report'));
const IvaReportPage = lazy(() => import('@/views/ivareport/ReportPage'));
function moduleRoutes(config) { function moduleRoutes(config) {
const routes = [ const routes = [
@ -225,6 +230,16 @@ export const routes = [
{ path: '/rcpareport/monthwise', element: <RcpaMonthWise /> }, { path: '/rcpareport/monthwise', element: <RcpaMonthWise /> },
{ path: '/rcpareport/formwise', element: <RcpaFormWise /> }, { path: '/rcpareport/formwise', element: <RcpaFormWise /> },
{ path: '/gennovadcreport/dashboard', element: <GennovadcDashboard /> }, { path: '/gennovadcreport/dashboard', element: <GennovadcDashboard /> },
{ path: '/zenovareport/dashboard', element: <ZenovaDashboard /> },
{ path: '/coachingtooldashboard/sbmandmeactionplanreport', element: <ActionPlanReport reportKey="sbm" /> },
{ path: '/coachingtooldashboard/rmactionplanreport', element: <ActionPlanReport reportKey="rm" /> },
{ path: '/coachingtooldashboard/sbmandmegoalandproficiencytrackingreport', element: <GoalProficiencyReport reportKey="sbm" /> },
{ path: '/coachingtooldashboard/rmgoalandproficiencytracking', element: <GoalProficiencyReport reportKey="rm" /> },
{ path: '/adherencepobdrivereport/report', element: <AdherencePobDriveReport /> },
{ path: '/ivareport/planner', element: <IvaReportPage key="planner" tab="planner" /> },
{ path: '/ivareport/kamwise', element: <IvaReportPage key="kamwise" tab="kamwise" /> },
{ path: '/ivareport/drbrandwise', element: <IvaReportPage key="drbrandwise" tab="drbrandwise" /> },
{ path: '/ivareport/skuwise', element: <IvaReportPage key="skuwise" tab="skuwise" /> },
{ path: '/coming-soon', element: <ComingSoon /> }, { path: '/coming-soon', element: <ComingSoon /> },
{ path: '*', element: <ComingSoon /> }, { path: '*', element: <ComingSoon /> },
], ],

View File

@ -0,0 +1,184 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Button, Container, Form, Nav } from 'react-bootstrap';
import { isSuccess, signedGet } from '@/api/client';
import { useAuth } from '@/auth/AuthProvider';
import PageMetaData from '@/components/PageMetaData';
import { exportAdherenceReport } from './exportXlsx';
import {
REPORT_TIMEOUT,
TAB_LABELS,
buildReport,
buildSections,
canViewReport,
cellClass,
currentMonth,
exportFilename,
headerClass,
reportMonths,
} from './helpers';
const TITLE = 'Adherence Pack POB Drive';
function HeaderText({ text }) {
const lines = String(text).split('\n');
return lines.map((line, index) => (
<Fragment key={index}>
{index > 0 ? <br /> : null}
{line}
</Fragment>
));
}
export default function AdherencePobDriveReport() {
const { domain, permissions, user } = useAuth();
const allowed = canViewReport({ domain, permissions, designation: user?.designation });
const months = useMemo(() => reportMonths(), []);
const [selected, setSelected] = useState(() => {
const month = currentMonth();
return months.includes(month) ? month : months[months.length - 1];
});
const [month, setMonth] = useState(selected);
const [loading, setLoading] = useState(allowed);
const [error, setError] = useState('');
const [report, setReport] = useState(null);
const [activeTab, setActiveTab] = useState('abm');
const [exporting, setExporting] = useState(false);
useEffect(() => {
if (!allowed) return undefined;
let cancelled = false;
setLoading(true);
setError('');
signedGet('reports', 'team2_reports/adherence_report', { month }, { timeout: REPORT_TIMEOUT }).then((response) => {
if (cancelled) return;
if (!isSuccess(response) || !response?.data || typeof response.data !== 'object') {
setReport(null);
setError(response?.message || 'Failed to load the adherence report.');
return;
}
setReport(buildReport(response.data));
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [allowed, month]);
const sections = useMemo(() => (report ? buildSections(report) : []), [report]);
const handleSubmit = (event) => {
event.preventDefault();
setActiveTab('abm');
setMonth(selected);
};
const handleExport = async () => {
setExporting(true);
setError('');
try {
await exportAdherenceReport(report, exportFilename(month));
} catch (err) {
setError(err.message || 'Failed to export the adherence report.');
} finally {
setExporting(false);
}
};
if (!allowed) {
return (
<Container fluid>
<PageMetaData title={TITLE} />
<div className="card mt-3">
<div className="card-body">
<h4 className="page-title">{TITLE}</h4>
<div className="alert alert-danger mb-0" role="alert">You do not have access to this report.</div>
</div>
</div>
</Container>
);
}
return (
<Container fluid>
<PageMetaData title={TITLE} />
<div className="card mt-3">
<div className="card-body">
<h4 className="page-title">{TITLE}</h4>
<p className="text-muted">Adherence Pack POB of at least ₹15,000. Changing the month reloads the report.</p>
{error ? <div className="alert alert-danger" role="alert">{error}</div> : null}
<Form onSubmit={handleSubmit} className="row g-2 align-items-end">
<div className="col-md-4">
<Form.Label htmlFor="pob-month">Month</Form.Label>
<Form.Select id="pob-month" value={selected} onChange={(event) => setSelected(event.target.value)}>
{months.map((option) => (
<option key={option} value={option}>{option}</option>
))}
</Form.Select>
</div>
<div className="col-auto">
<Button type="submit" variant="primary" disabled={loading}>Show report</Button>
</div>
{!loading && sections.length ? (
<div className="col-auto">
<Button variant="outline-primary" onClick={handleExport} disabled={exporting}>
{exporting ? 'Exporting...' : 'Download Excel'}
</Button>
</div>
) : null}
</Form>
{loading ? <p className="text-muted fw-bold mt-2 mb-0">Loading the report wait.....</p> : null}
</div>
</div>
{!loading && sections.length ? (
<div className="card">
<div className="card-body">
<Nav variant="tabs" activeKey={activeTab} onSelect={(key) => setActiveTab(key)}>
{sections.map((section) => (
<Nav.Item key={section.id}>
<Nav.Link eventKey={section.id}>{TAB_LABELS[section.id]}</Nav.Link>
</Nav.Item>
))}
</Nav>
<div className="tab-content pt-3">
{sections.filter((section) => section.id === activeTab).map((section) => (
<div key={section.id} className="pob-table-wrap">
<table className="table table-bordered table-sm pob-table mb-0">
<thead>
<tr>
{section.dayTypes.map((type, index) => (
<th key={index} className={type === 'H' ? 'pob-day-h' : type === 'W' ? 'pob-day-w' : ''}>{type}</th>
))}
</tr>
<tr>
{section.headers.map((header, index) => (
<th key={index} className={headerClass(section, index)}>
<HeaderText text={header} />
</th>
))}
</tr>
</thead>
<tbody>
{section.rows.length ? section.rows.map((row, rowIndex) => (
<tr key={rowIndex} className={row.vacant ? 'pob-vacant' : ''}>
{row.cells.map((cell, index) => (
<td key={index} className={cellClass(section, row, cell, index)}>{String(cell)}</td>
))}
</tr>
)) : (
<tr>
<td colSpan={section.headers.length}>No rows for this month.</td>
</tr>
)}
</tbody>
</table>
</div>
))}
</div>
</div>
</div>
) : null}
</Container>
);
}

View File

@ -0,0 +1,244 @@
import {
ABM_IDENTITY,
ABM_SUMMARY,
TOTAL_CHEMISTS,
dayHeader,
dayValue,
field,
isVacantName,
regionCell,
regionColumns,
} from './helpers';
const GREEN = 'FFC6EFCE';
const GREY = 'FFD9D9D9';
const PINK = 'FFF8CBAD';
const RED = 'FFC00000';
const MAROON = 'FF8A1538';
const WHITE = 'FFFFFFFF';
const BLUE = 'FF0000FF';
const BLACK = 'FF000000';
// PhpSpreadsheet's default binder stores numeric text as numbers, except values with a leading zero.
function cellValue(value) {
if (typeof value !== 'string' || !/^[+-]?(\d+\.?\d*|\d*\.?\d+)([Ee][-+]?[0-2]?\d{1,3})?$/.test(value)) return value;
const digits = value.replace(/^[+-]+/, '');
if (digits.length > 1 && digits[0] === '0' && digits[1] !== '.') return value;
if (!value.includes('.') && !Number.isSafeInteger(Number(value))) return value;
return Number(value);
}
function writeValues(sheet, row, values, startColumn = 1) {
values.forEach((value, offset) => {
sheet.getCell(row, startColumn + offset).value = cellValue(value);
});
}
function align(cell, alignment) {
cell.alignment = { ...(cell.alignment || {}), ...alignment };
}
function setFont(cell, font) {
cell.font = { ...(cell.font || {}), ...font };
}
function fill(cell, color) {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: color } };
}
function paintCell(cell, color) {
fill(cell, color);
align(cell, { horizontal: 'center', vertical: 'middle' });
}
function eachCell(sheet, row, start, end, visit) {
for (let col = start; col <= end; col += 1) visit(sheet.getCell(row, col));
}
function paintHeaderGroup(sheet, row, start, end, color, size) {
eachCell(sheet, row, start, end, (cell) => {
fill(cell, color);
setFont(cell, { name: 'Century Gothic', bold: true, size, color: { argb: WHITE } });
align(cell, { wrapText: true, horizontal: 'center', vertical: 'middle' });
});
}
function styleHeaderRow(sheet, row, lastColumn, identityCount, summaryStart) {
eachCell(sheet, row, 1, lastColumn, (cell) => align(cell, { wrapText: true, vertical: 'middle', horizontal: 'center' }));
sheet.getRow(row).height = 36;
if (identityCount > 0) paintHeaderGroup(sheet, row, 1, identityCount, RED, 10);
if (summaryStart > identityCount && summaryStart <= lastColumn) paintHeaderGroup(sheet, row, summaryStart, lastColumn, MAROON, 9);
}
function paintDayTypes(sheet, row, startColumn, template) {
template.forEach((day, index) => paintCell(sheet.getCell(row, startColumn + index), day.type === 'H' ? GREY : GREEN));
}
function paintDayHeaders(sheet, row, startColumn, template) {
template.forEach((day, index) => {
const cell = sheet.getCell(row, startColumn + index);
paintCell(cell, day.type === 'H' ? GREY : GREEN);
setFont(cell, { bold: true, size: 11, color: { argb: BLACK } });
});
}
function paintDataDays(sheet, row, startColumn, template) {
template.forEach((day, index) => {
if (day.type === 'H') paintCell(sheet.getCell(row, startColumn + index), GREY);
});
}
function paintRow(sheet, row, lastColumn, color) {
eachCell(sheet, row, 1, lastColumn, (cell) => fill(cell, color));
}
function finishSheet(sheet, headerRow, lastRow, lastColumn, dayStart, titleSize) {
setFont(sheet.getCell('A1'), { name: 'Century Gothic', bold: true, size: titleSize, color: { argb: MAROON } });
if (lastRow > 1) {
for (let row = 2; row <= lastRow; row += 1) {
eachCell(sheet, row, 1, lastColumn, (cell) => align(cell, { horizontal: 'center', vertical: 'middle' }));
}
}
const thin = { style: 'thin', color: { argb: BLACK } };
for (let row = headerRow; row <= lastRow; row += 1) {
eachCell(sheet, row, 1, lastColumn, (cell) => {
cell.border = { top: thin, left: thin, bottom: thin, right: thin };
});
}
for (let col = 1; col <= lastColumn; col += 1) {
sheet.getColumn(col).width = col < dayStart ? 22 : 14;
}
}
function writeAbmSheet(sheet, report) {
const { abmRows, template, dayMap } = report;
const dayStart = ABM_IDENTITY.length + 1;
const summaryStart = dayStart + template.length;
const lastCol = summaryStart + ABM_SUMMARY.length - 1;
sheet.getCell('A1').value = 'Adherence Pack POB Drive – ABM';
sheet.getCell('A3').value = 'Data of no. of chemists with Adherence Pack POB ≥ ₹15,000 will be added by Smartian App';
template.forEach((day, index) => {
sheet.getCell(4, dayStart + index).value = day.type === 'H' ? 'H' : 'W';
});
writeValues(sheet, 5, [...ABM_IDENTITY, ...template.map((day) => dayHeader(day.label)), ...ABM_SUMMARY]);
styleHeaderRow(sheet, 5, lastCol, ABM_IDENTITY.length, summaryStart);
setFont(sheet.getCell('A3'), { name: 'Century Gothic', bold: true, size: 9, color: { argb: BLUE } });
paintDayTypes(sheet, 4, dayStart, template);
paintDayHeaders(sheet, 5, dayStart, template);
let rowNumber = 6;
abmRows.forEach((row) => {
const days = dayMap(field(row, 'days'));
writeValues(sheet, rowNumber, [
...ABM_IDENTITY.map((key) => field(row, key)),
...template.map((day) => dayValue(days[day.date] ?? day, 'value')),
...ABM_SUMMARY.map((key) => field(row, key)),
]);
paintDataDays(sheet, rowNumber, dayStart, template);
if (isVacantName(field(row, 'ABM HQ')) || isVacantName(field(row, 'ABM Name'))) paintRow(sheet, rowNumber, lastCol, PINK);
rowNumber += 1;
});
finishSheet(sheet, 5, Math.max(5, rowNumber - 1), lastCol, dayStart, 14);
align(sheet.getCell('A3'), { horizontal: 'left' });
sheet.views = [{ state: 'frozen', xSplit: 0, ySplit: 5 }];
}
function writeRegionSheet(sheet, report) {
const { rbmRows, template, dayMap } = report;
const columns = regionColumns(template);
const lastCol = 3 + columns.length;
sheet.getCell('A1').value = 'Region-wise Daily Winner – Highest no. of chemists with Adherence Pack POB ≥ ₹15,000';
const typeRow = ['', '', ''];
const headerRow = ['Region (RBM)', 'RBM Name', 'RBM Employee Code'];
columns.forEach((column) => {
const total = column.kind === 'total-tm' || column.kind === 'total-chemist';
typeRow.push(total ? '' : column.type === 'H' ? 'H' : 'W');
headerRow.push(column.header);
});
writeValues(sheet, 3, typeRow);
writeValues(sheet, 4, headerRow);
styleHeaderRow(sheet, 4, lastCol, 3, lastCol - 1);
columns.forEach((column, index) => {
if (column.kind === 'total-tm' || column.kind === 'total-chemist') return;
const color = column.type === 'H' ? GREY : GREEN;
paintCell(sheet.getCell(3, 4 + index), color);
const header = sheet.getCell(4, 4 + index);
paintCell(header, color);
setFont(header, { bold: true, size: 11, color: { argb: BLACK } });
});
let rowNumber = 5;
rbmRows.forEach((row) => {
const days = dayMap(field(row, 'days'));
const values = [
field(row, 'Region (RBM)'),
field(row, 'RBM Name'),
field(row, 'RBM Employee Code'),
...columns.map((column) => regionCell(row, column, days)),
];
writeValues(sheet, rowNumber, values);
columns.forEach((column, index) => {
if (column.type === 'H') paintCell(sheet.getCell(rowNumber, 4 + index), GREY);
});
if (isVacantName(values[0]) || isVacantName(values[1])) paintRow(sheet, rowNumber, lastCol, PINK);
rowNumber += 1;
});
finishSheet(sheet, 4, Math.max(4, rowNumber - 1), lastCol, 4, 13);
sheet.views = [{ state: 'frozen', xSplit: 0, ySplit: 4 }];
}
function writeTerritorySheet(sheet, report) {
const { tmRows, template, dayMap } = report;
const dayStart = 4;
const totalCol = dayStart + template.length;
sheet.getCell('A1').value = 'Territory-wise Daily Winner – Highest no. of chemists with Adherence Pack POB ≥ ₹15,000';
writeValues(sheet, 3, ['', '', '', ...template.map((day) => (day.type === 'H' ? 'H' : 'W'))]);
writeValues(sheet, 4, ['Territory', 'TM Name', 'TM Employee Code', ...template.map((day) => dayHeader(day.label)), TOTAL_CHEMISTS]);
styleHeaderRow(sheet, 4, totalCol, 3, totalCol);
paintDayTypes(sheet, 3, dayStart, template);
paintDayHeaders(sheet, 4, dayStart, template);
let rowNumber = 5;
tmRows.forEach((row) => {
const days = dayMap(field(row, 'days'));
const values = [
field(row, 'Territory'),
field(row, 'TM Name'),
field(row, 'TM Employee Code'),
...template.map((day) => dayValue(days[day.date] ?? day, 'value')),
field(row, TOTAL_CHEMISTS),
];
writeValues(sheet, rowNumber, values);
paintDataDays(sheet, rowNumber, dayStart, template);
if (isVacantName(values[0]) || isVacantName(values[1])) paintRow(sheet, rowNumber, totalCol, PINK);
rowNumber += 1;
});
finishSheet(sheet, 4, Math.max(4, rowNumber - 1), totalCol, dayStart, 13);
sheet.views = [{ state: 'frozen', xSplit: 0, ySplit: 4 }];
}
export async function exportAdherenceReport(report, filename) {
const { default: ExcelJS } = await import('exceljs');
const workbook = new ExcelJS.Workbook();
writeAbmSheet(workbook.addWorksheet('Details ABM wise'), report);
writeRegionSheet(workbook.addWorksheet('Region Daily Winners'), report);
writeTerritorySheet(workbook.addWorksheet('Territory Daily Winners'), report);
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const anchor = document.createElement('a');
anchor.href = URL.createObjectURL(blob);
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(anchor.href);
}

View File

@ -0,0 +1,238 @@
export const REPORT_TIMEOUT = 120000;
const REPORT_START = new Date(2026, 3, 1);
export const ABM_IDENTITY = [
'ZBM',
'RBM',
'ABM HQ',
'HQ Div',
'ABM Name',
'Emp ID',
'Reporting Manager (RBM)',
'Email',
'Approved Count per ABM',
'Remarks',
];
export const ABM_SUMMARY = [
'Working Days',
'Daily Target (POBs = Approved Count)',
'Month Target (POBs)',
'Total TMs Qualified (≥₹15k POB)',
'Achievement %',
'Total Chemists Qualified (≥₹15k POB)',
'Days Daily Target Met',
'Target Status',
];
export const TOTAL_TMS = 'Total TMs Qualified (≥₹15k POB)';
export const TOTAL_CHEMISTS = 'Total Chemists Qualified (≥₹15k POB)';
export const TAB_LABELS = {
abm: 'Details ABM wise',
region: 'Region Daily Winners',
territory: 'Territory Daily Winners',
};
function monthLabel(date) {
return date.toLocaleString('en-US', { month: 'long', year: 'numeric' });
}
export function currentMonth() {
return monthLabel(new Date());
}
export function reportMonths(now = new Date()) {
const end = new Date(now.getFullYear(), now.getMonth(), 1);
const months = [];
for (let cursor = new Date(REPORT_START); cursor <= end; cursor = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1)) {
months.push(monthLabel(cursor));
}
return months.length ? months : [currentMonth()];
}
export function canViewReport({ domain, permissions = [], designation }) {
if (domain !== 'team2') return false;
if (permissions.includes('all_data_access')) return true;
const value = String(designation ?? '').trim().toUpperCase();
return value === 'HO' || value === 'ADMIN';
}
export function exportFilename(month) {
const slug = String(month).replace(/[^A-Za-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return `Adherence-Pack-POB-Drive-${slug || 'report'}.xlsx`;
}
function isNumeric(value) {
if (typeof value === 'number') return Number.isFinite(value);
if (typeof value !== 'string' || value.trim() === '') return false;
return Number.isFinite(Number(value));
}
export function field(row, key) {
if (!row || typeof row !== 'object') return '';
return row[key] ?? '';
}
function listRows(data, key) {
const rows = field(data, key);
if (Array.isArray(rows)) return rows;
if (rows && typeof rows === 'object') return Object.values(rows);
return [];
}
function orderedDays(days) {
if (!days || typeof days !== 'object') return [];
const list = [];
Object.entries(days).forEach(([label, day]) => {
if (!day || typeof day !== 'object') return;
list.push({
label: String(label),
date: String(day.date ?? ''),
type: String(day.type ?? 'W'),
value: day.value ?? 0,
chemistsQualified: day.chemistsQualified ?? 0,
});
});
return list.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
}
function dayMap(days) {
const map = {};
orderedDays(days).forEach((day) => {
if (day.date !== '') map[day.date] = day;
});
return map;
}
export function dayValue(day, key) {
if (day?.type === 'H' || day?.[key] === 'Holiday') return 'Holiday';
const value = day?.[key] ?? 0;
return isNumeric(value) ? Number(value) : value;
}
export function dayHeader(label) {
const match = String(label).trim().match(/^(\S+)\s+([\s\S]+)$/);
return match ? `${match[1]}\n${match[2]}` : String(label);
}
export function isVacantName(name) {
return String(name ?? '').trim().toLowerCase().includes('vacant');
}
function dayTemplate(lists) {
for (const rows of lists) {
if (!rows.length) continue;
const days = orderedDays(field(rows[0], 'days'));
if (days.length) return days;
}
return [];
}
export function regionColumns(template) {
const columns = [];
template.forEach((day) => {
const header = dayHeader(day.label);
if (day.type === 'H') {
columns.push({ kind: 'holiday', type: 'H', date: day.date, header, day });
return;
}
columns.push({ kind: 'tm', type: 'W', date: day.date, header, day });
columns.push({ kind: 'chemist', type: 'W', date: day.date, header, day });
});
columns.push({ kind: 'total-tm', type: '', header: TOTAL_TMS });
columns.push({ kind: 'total-chemist', type: '', header: TOTAL_CHEMISTS });
return columns;
}
export function regionCell(row, column, days) {
if (column.kind === 'total-tm') return field(row, TOTAL_TMS);
if (column.kind === 'total-chemist') return field(row, TOTAL_CHEMISTS);
if (column.kind === 'holiday') return 'Holiday';
const day = days[column.date] ?? column.day;
return dayValue(day, column.kind === 'chemist' ? 'chemistsQualified' : 'value');
}
export function buildReport(data) {
const abmRows = listRows(data, 'abmReport');
const rbmRows = listRows(data, 'rbmReport');
const tmRows = listRows(data, 'tmReport');
const template = dayTemplate([abmRows, rbmRows, tmRows]);
return { abmRows, rbmRows, tmRows, template, dayMap };
}
function abmSection(report) {
const { abmRows, template } = report;
const dayTypes = [
...ABM_IDENTITY.map(() => ''),
...template.map((day) => (day.type === 'H' ? 'H' : 'W')),
...ABM_SUMMARY.map(() => ''),
];
const headers = [...ABM_IDENTITY, ...template.map((day) => dayHeader(day.label)), ...ABM_SUMMARY];
const rows = abmRows.map((row) => {
const days = dayMap(field(row, 'days'));
return {
cells: [
...ABM_IDENTITY.map((key) => field(row, key)),
...template.map((day) => dayValue(days[day.date] ?? day, 'value')),
...ABM_SUMMARY.map((key) => field(row, key)),
],
vacant: isVacantName(field(row, 'ABM HQ')) || isVacantName(field(row, 'ABM Name')),
};
});
return { id: 'abm', dayTypes, headers, rows };
}
function regionSection(report) {
const columns = regionColumns(report.template);
const dayTypes = ['', '', '', ...columns.map((column) => column.type)];
const headers = ['Region (RBM)', 'RBM Name', 'RBM Employee Code', ...columns.map((column) => column.header)];
const rows = report.rbmRows.map((row) => {
const days = dayMap(field(row, 'days'));
const cells = [
field(row, 'Region (RBM)'),
field(row, 'RBM Name'),
field(row, 'RBM Employee Code'),
...columns.map((column) => regionCell(row, column, days)),
];
return { cells, vacant: isVacantName(cells[0]) || isVacantName(cells[1]) };
});
return { id: 'region', dayTypes, headers, rows };
}
function territorySection(report) {
const { tmRows, template } = report;
const dayTypes = ['', '', '', ...template.map((day) => (day.type === 'H' ? 'H' : 'W')), ''];
const headers = ['Territory', 'TM Name', 'TM Employee Code', ...template.map((day) => dayHeader(day.label)), TOTAL_CHEMISTS];
const rows = tmRows.map((row) => {
const days = dayMap(field(row, 'days'));
const cells = [
field(row, 'Territory'),
field(row, 'TM Name'),
field(row, 'TM Employee Code'),
...template.map((day) => dayValue(days[day.date] ?? day, 'value')),
field(row, TOTAL_CHEMISTS),
];
return { cells, vacant: isVacantName(cells[0]) || isVacantName(cells[1]) };
});
return { id: 'territory', dayTypes, headers, rows };
}
export function buildSections(report) {
return [abmSection(report), regionSection(report), territorySection(report)];
}
export function headerClass(section, index) {
const type = section.dayTypes[index] ?? '';
if (type === 'H') return 'pob-day-h';
if (type === 'W') return 'pob-day-w';
const firstDay = section.dayTypes.findIndex((value) => value === 'W' || value === 'H');
return firstDay !== -1 && index < firstDay ? 'pob-identity' : 'pob-summary';
}
export function cellClass(section, row, cell, index) {
if (row.vacant) return '';
const type = section.dayTypes[index] ?? '';
return type === 'H' || cell === 'Holiday' ? 'pob-day-h' : '';
}

View File

@ -0,0 +1,102 @@
import { useEffect, useState } from 'react';
import { Button, Container, Spinner, Table } from 'react-bootstrap';
import { isSuccess, signedGet, signedPost } from '@/api/client';
import { useAuth } from '@/auth/AuthProvider';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import { exportActionPlan } from './exportXlsx';
import { ACTION_REPORTS, REPORT_TIMEOUT, actionColumns, actionRequest, actionRows, columnLabel, submissionList } from './helpers';
export default function ActionPlanReport({ reportKey }) {
const report = ACTION_REPORTS[reportKey];
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [exporting, setExporting] = useState(false);
const [error, setError] = useState('');
const [exportError, setExportError] = useState('');
const [rows, setRows] = useState([]);
const columns = actionColumns();
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
const load = async () => {
let hierarchyIds = null;
if (!hasPermission('all_data_access')) {
const hierarchy = await signedGet('ums', 'hierarchy/hierarchy_below_me', {}, { timeout: REPORT_TIMEOUT });
if (!isSuccess(hierarchy)) {
return { error: hierarchy?.message || 'Unable to load hierarchy', rows: [] };
}
const list = Array.isArray(hierarchy.data?.hierarchies) ? hierarchy.data.hierarchies : [];
hierarchyIds = list.map((item) => item.id).filter(Boolean);
}
const response = await signedPost('form', 'formentity/submission_aggregation', actionRequest(report, hierarchyIds), { timeout: REPORT_TIMEOUT });
if (!isSuccess(response)) return { error: response?.message || 'Unable to load the report', rows: [] };
return { error: '', rows: actionRows(submissionList(response), report.plans) };
};
load().then((result) => {
if (cancelled) return;
setError(result.error);
setRows(result.rows);
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [hasPermission, report]);
const handleExport = async () => {
setExporting(true);
setExportError('');
try {
await exportActionPlan(report, rows);
} catch (err) {
setExportError(err.message || 'Unable to export the report');
} finally {
setExporting(false);
}
};
return (
<Container fluid>
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
<PageBreadcrumb title={report.title} />
<Button variant="primary" onClick={handleExport} disabled={loading || exporting || Boolean(error)}>
{exporting ? 'Exporting...' : 'Export'}
</Button>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{exportError ? <div className="alert alert-danger">{exportError}</div> : null}
<div className="card">
<div className="card-body position-relative coaching-scroll">
{loading ? (
<div className="coaching-loading">
<Spinner animation="border" />
</div>
) : null}
<Table bordered striped className="coaching-table coaching-action mb-0">
<thead>
<tr>
<th className="sticky-col">{report.columnTitle}</th>
{columns.map((column) => (
<th key={column}>{column === 'total' ? 'Total' : columnLabel(column)}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.label}>
<td className="sticky-col">{row.label}</td>
{columns.map((column) => (
<td key={column}>{row[column] ?? 0}</td>
))}
</tr>
))}
</tbody>
</Table>
</div>
</div>
</Container>
);
}

View File

@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { Button, Container, Spinner, Table } from 'react-bootstrap';
import { isSuccess, signedGet, signedPost } from '@/api/client';
import { useAuth } from '@/auth/AuthProvider';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import { exportGoalReport } from './exportXlsx';
import { GOAL_REPORTS, RATINGS, REPORT_TIMEOUT, goalCount, goalGrid, goalRequest, monthHeading, submissionList } from './helpers';
const EMPTY_GRID = { months: [], byMonth: {}, goals: [] };
export default function GoalProficiencyReport({ reportKey }) {
const report = GOAL_REPORTS[reportKey];
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [exporting, setExporting] = useState(false);
const [error, setError] = useState('');
const [exportError, setExportError] = useState('');
const [grid, setGrid] = useState(EMPTY_GRID);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
const load = async () => {
let hierarchyIds = null;
if (!hasPermission('all_data_access')) {
const hierarchy = await signedGet('ums', 'hierarchy/hierarchy_below_me', {}, { timeout: REPORT_TIMEOUT });
if (!isSuccess(hierarchy)) {
return { error: hierarchy?.message || 'Unable to load hierarchy', grid: EMPTY_GRID };
}
const list = Array.isArray(hierarchy.data?.hierarchies) ? hierarchy.data.hierarchies : [];
hierarchyIds = list.map((item) => item.id).filter(Boolean);
}
const response = await signedPost('form', 'formentity/submission_aggregation', goalRequest(report, hierarchyIds), { timeout: REPORT_TIMEOUT });
if (!isSuccess(response)) return { error: response?.message || 'Unable to load the report', grid: { ...EMPTY_GRID, goals: report.goals.map((goal) => goal.name) } };
return { error: '', grid: goalGrid(submissionList(response), report.goals) };
};
load().then((result) => {
if (cancelled) return;
setError(result.error);
setGrid(result.grid);
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [hasPermission, report]);
const handleExport = async () => {
setExporting(true);
setExportError('');
try {
await exportGoalReport(report, grid);
} catch (err) {
setExportError(err.message || 'Unable to export the report');
} finally {
setExporting(false);
}
};
return (
<Container fluid>
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
<PageBreadcrumb title={report.title} />
<Button variant="primary" onClick={handleExport} disabled={loading || exporting || Boolean(error)}>
{exporting ? 'Exporting...' : 'Export'}
</Button>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
{exportError ? <div className="alert alert-danger">{exportError}</div> : null}
<div className="card">
<div className="card-body position-relative coaching-scroll">
{loading ? (
<div className="coaching-loading">
<Spinner animation="border" />
</div>
) : null}
<Table bordered striped className="coaching-table coaching-goal mb-0">
<thead>
<tr>
<th rowSpan={2} className="sticky-col text-start">{report.columnTitle}</th>
{grid.months.map((month) => (
<th key={month} colSpan={RATINGS.length} className="text-center">{monthHeading(month)}</th>
))}
</tr>
<tr>
{grid.months.flatMap((month) => RATINGS.map((rating) => (
<th key={`${month}-${rating}`} className="text-center">{rating}</th>
)))}
</tr>
</thead>
<tbody>
{grid.goals.map((goalName, index) => (
<tr key={goalName}>
<td className="sticky-col text-start">Goal {index + 1}: {goalName}*</td>
{grid.months.flatMap((month) => RATINGS.map((rating) => (
<td key={`${goalName}-${month}-${rating}`} className="text-center">
{goalCount(grid.byMonth, month, goalName, rating)}
</td>
)))}
</tr>
))}
</tbody>
</Table>
</div>
</div>
</Container>
);
}

View File

@ -0,0 +1,70 @@
import { RATINGS, actionColumns, goalCount } from './helpers';
function fileStamp(now = new Date()) {
const day = String(now.getDate()).padStart(2, '0');
const month = String(now.getMonth() + 1).padStart(2, '0');
const year = String(now.getFullYear()).slice(2);
return `${day}-${month}-${year}`;
}
function countCell(value) {
if (value == null || value === '') return 0;
const number = Number(value);
return Number.isFinite(number) ? number : value;
}
function exportHeader(key) {
if (key === 'total') return 'Total';
return String(key).replaceAll('_', ' ').replace(/\b[a-z]/g, (letter) => letter.toUpperCase());
}
function exportMonth(month) {
if (!/^\d{4}-\d{2}$/.test(month)) return month;
const [year, mon] = month.split('-');
const label = new Date(Number(year), Number(mon) - 1, 1).toLocaleString('en-US', { month: 'short' });
return `${label}-${year}`;
}
async function downloadXlsx(filename, lines) {
const { default: ExcelJS } = await import('exceljs');
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Worksheet');
lines.forEach((line) => sheet.addRow(line));
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const anchor = document.createElement('a');
anchor.href = URL.createObjectURL(blob);
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(anchor.href);
}
export async function exportActionPlan(report, rows) {
const columns = actionColumns();
const lines = [
[report.columnTitle, ...columns.map((column) => exportHeader(column))],
...rows.map((row) => [row.label, ...columns.map((column) => countCell(row[column]))]),
];
await downloadXlsx(`${report.exportFile}_${fileStamp()}.xlsx`, lines);
}
export async function exportGoalReport(report, grid) {
const header = [report.columnTitle];
const ratings = [''];
grid.months.forEach((month) => {
const label = exportMonth(month);
RATINGS.forEach(() => header.push(label));
RATINGS.forEach((rating) => ratings.push(rating));
});
const lines = [
header,
ratings,
...grid.goals.map((goalName) => [
goalName,
...grid.months.flatMap((month) => RATINGS.map((rating) => countCell(goalCount(grid.byMonth, month, goalName, rating)))),
]),
];
await downloadXlsx(`${report.exportFile}_${fileStamp()}.xlsx`, lines);
}

View File

@ -0,0 +1,384 @@
export const REPORT_TIMEOUT = 120000;
const SBM_FORM = {
partition: 'rapport-pulmocare-1742292203-0153c37f-1433-446a-a059-de3597eaca58',
formId: '1742292203-0153c37f-1433-446a-a059-de3597eaca58',
};
const RM_FORM = {
partition: 'rapport-pulmocare-1742297848-159745be-ac42-413b-9aee-31466671011e',
formId: '1742297848-159745be-ac42-413b-9aee-31466671011e',
};
const ACTION_COLUMNS = ['total', 'less_than_1_month', '1_month', '3_months', '6_months', 'on_track', 'needs_correction', 'status_quo'];
const SBM_PLANS = {
'100% adherence in Tour plan': ['100_adherence_in_tour_plan'],
'95% MCL Coverage, Visit frequency >90%, 5% Non visited doctors': ['95_mcl_coverage_visit_frequency_90_5_non_visited_doctors'],
'1 Camp & 4 Clinics': ['1_camp_and_4_clinics', '1_camp_4_clinics'],
'Tab Detailing in doctor calls': ['tab_detailing_in_doctor_calls'],
'New Conversions': ['new_conversions'],
'MCL productivity': ['mcl_productivity'],
'Others mention if any': ['others_mention_if_any'],
};
const RM_PLANS = {
'Tour Plan adherence of Team': ['tour_plan_adherence_of_team'],
'100% Spread of performance': ['100_spread_of_performance'],
'100% RM Region Target achievement': ['100_rm_region_target_achievement'],
'SOP at Mandate Brand performance': ['sop_at_mandate_brand_performance'],
'RM Mandate Target Achievement': ['rm_mandate_target_achievement'],
'SOP at NI Brand performance': ['sop_at_ni_brand_performance'],
'RM NI Target Achievement': ['rm_ni_target_achievement'],
'Stretch working with SBMs': ['stretch_working_with_sbms'],
'Critical Geography time allocation': ['critical_geography_time_allocation'],
'Critical geography Revival': ['critical_geography_revival'],
'Covering all Sponsored doctors': ['covering_all_sponsored_doctors'],
'Ensuring equal spread at all doctors': ['ensuring_equal_spread_at_all_doctors'],
'Ensuring 1:5 times ROI from Sponsored doctors': ['ensuring_1_5_times_roi_from_sponsored_doctors'],
Others: ['others'],
};
const SBM_GOALS = [
{ name: 'Tour Plan Adherence', field: '$formdata.proficiency-goal_1_tour_plan_adherance' },
{ name: 'Basic Working', field: '$formdata.proficiency-goal_2_basic_working' },
{ name: 'Activations', field: '$formdata.proficiency-goal_3_activations' },
{ name: 'In-clinic Effectiveness', field: '$formdata.proficiency-goal_4_in_clinic_effectiveness' },
{ name: 'Spread of Performance', field: '$formdata.proficiency-goal_5_spread_of_performance' },
];
const RM_GOALS = [
{ name: 'Tour Plan Adherence Self & Team', field: '$formdata.proficiency-goal_1_tour_plan_adherence_self_and_team' },
{ name: 'Spread of Performance', field: '$formdata.proficiency-goal_2_spread_of_performance' },
{ name: 'Joint Field Work', field: '$formdata.proficiency-goal_3_joint_field_work' },
{ name: 'KOL Engagement & ROI', field: '$formdata.proficiency-goal_4_kol_engagement_and_roi' },
];
export const RATINGS = ['Proficient', 'Competent', 'Intermediate', 'Beginner'];
export const ACTION_REPORTS = {
sbm: {
title: 'SBM/ME Action Plan Timeline',
request: SBM_FORM,
match: formMatch(SBM_FORM),
plans: SBM_PLANS,
exportFile: 'sbmme_actionplan_report',
columnTitle: 'SBM/ME Action Plan',
},
rm: {
title: 'RM Action Plan Timeline',
request: RM_FORM,
match: formMatch(RM_FORM),
plans: RM_PLANS,
exportFile: 'rm_actionplan_report',
columnTitle: 'RM Action Plan',
},
};
export const GOAL_REPORTS = {
sbm: {
title: 'SBM/ME Goal & Proficiency Tracking',
request: SBM_FORM,
match: formMatch(SBM_FORM, { is_deleted: { $ne: true } }),
goals: SBM_GOALS,
exportFile: 'sbm_goalproficiency_report',
columnTitle: 'SBM/ME Goals',
},
rm: {
title: 'RM Goal & Proficiency Tracking',
request: RM_FORM,
match: formMatch(RM_FORM, { is_deleted: { $ne: true } }),
goals: RM_GOALS,
exportFile: 'rm_goalproficiency_report',
columnTitle: 'RM Goals',
},
};
function formMatch(form, extra = {}) {
return {
form_id: form.formId,
partition: form.partition,
status: 'active',
...extra,
};
}
function slugify(value) {
return String(value ?? '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
}
function isNumeric(value) {
if (typeof value === 'number') return Number.isFinite(value);
if (typeof value !== 'string' || value.trim() === '') return false;
return Number.isFinite(Number(value));
}
export function columnLabel(key) {
const text = String(key).replaceAll('_', ' ');
return text.charAt(0).toUpperCase() + text.slice(1);
}
export function actionColumns() {
return ACTION_COLUMNS;
}
function actionPipeline(match) {
return [
{ $match: match },
{
$addFields: {
action_plan_array: {
$let: {
vars: { ap: '$formdata.action_plan' },
in: {
$cond: [
{ $eq: [{ $type: '$$ap' }, 'array'] },
'$$ap',
{ $cond: [{ $eq: [{ $type: '$$ap' }, 'string'] }, { $split: ['$$ap', ','] }, []] },
],
},
},
},
},
},
{ $unwind: { path: '$action_plan_array', preserveNullAndEmptyArrays: false } },
{ $addFields: { actionKey: '$action_plan_array', actionLabel: '$action_plan_array' } },
{
$addFields: {
timelineKey: { $toLower: { $ifNull: ['$formdata.timeline_agreed_for_selected_action_plans_completion', ''] } },
adherenceKeyRaw: { $toLower: { $ifNull: ['$formdata.adherance_to_action_plan', ''] } },
},
},
{
$addFields: {
adherenceKey: { $cond: [{ $eq: ['$adherenceKeyRaw', 'staus_quo'] }, 'status_quo', '$adherenceKeyRaw'] },
},
},
{
$group: {
_id: '$actionKey',
anyLabel: { $first: '$actionLabel' },
total: { $sum: 1 },
less_than_1_month: { $sum: { $cond: [{ $eq: ['$timelineKey', 'less_than_1_month'] }, 1, 0] } },
'1_month': { $sum: { $cond: [{ $eq: ['$timelineKey', '1_month'] }, 1, 0] } },
'3_months': { $sum: { $cond: [{ $eq: ['$timelineKey', '3_month'] }, 1, 0] } },
'6_months': { $sum: { $cond: [{ $eq: ['$timelineKey', '6_month'] }, 1, 0] } },
on_track: { $sum: { $cond: [{ $eq: ['$adherenceKey', 'on_track'] }, 1, 0] } },
needs_correction: { $sum: { $cond: [{ $eq: ['$adherenceKey', 'needs_correction'] }, 1, 0] } },
status_quo: { $sum: { $cond: [{ $eq: ['$adherenceKey', 'status_quo'] }, 1, 0] } },
},
},
{
$project: {
_id: 0,
action_plan_label: '$anyLabel',
total: 1,
less_than_1_month: 1,
'1_month': 1,
'3_months': 1,
'6_months': 1,
on_track: 1,
needs_correction: 1,
status_quo: 1,
},
},
{ $sort: { total: -1 } },
];
}
function monthField(path, type) {
return {
$cond: [
{ $eq: [{ $type: path }, type] },
type === 'string' ? { $substr: [path, 0, 7] } : { $substr: [{ $dateToString: { format: '%Y-%m-%d', date: path } }, 0, 7] },
null,
],
};
}
function goalPipeline(match, goals) {
return [
{ $match: match },
{
$addFields: {
_m_from_record_date: monthField('$record_date', 'date'),
_m_from_formdata_str: monthField('$formdata.record_date', 'string'),
_m_from_formdata_date: monthField('$formdata.record_date', 'date'),
_m_from_createdAt: monthField('$createdAt', 'date'),
},
},
{
$addFields: {
record_month: {
$ifNull: ['$_m_from_record_date', { $ifNull: ['$_m_from_formdata_str', { $ifNull: ['$_m_from_formdata_date', '$_m_from_createdAt'] }] }],
},
},
},
{
$project: {
record_month: 1,
user_id: 1,
goals: goals.map((goal) => ({ goal_name: goal.name, goal_value: goal.field })),
},
},
{ $unwind: '$goals' },
{ $match: { record_month: { $ne: null }, 'goals.goal_value': { $ne: null } } },
{
$addFields: {
'goals.goal_value_norm': {
$switch: {
branches: [
{ case: { $eq: [{ $substr: ['$goals.goal_value', 0, 1] }, 'A'] }, then: 'Proficient' },
{ case: { $eq: [{ $substr: ['$goals.goal_value', 0, 1] }, 'B'] }, then: 'Competent' },
{ case: { $eq: [{ $substr: ['$goals.goal_value', 0, 1] }, 'C'] }, then: 'Intermediate' },
{ case: { $eq: [{ $substr: ['$goals.goal_value', 0, 1] }, 'D'] }, then: 'Beginner' },
],
default: '$goals.goal_value',
},
},
},
},
{
$group: {
_id: { month: '$record_month', goal_name: '$goals.goal_name', goal_value: '$goals.goal_value_norm' },
users: { $addToSet: '$user_id' },
},
},
{
$project: {
_id: 0,
month: '$_id.month',
goal_name: '$_id.goal_name',
goal_value: '$_id.goal_value',
user_count: { $size: '$users' },
},
},
{
$group: {
_id: { month: '$month', goal_name: '$goal_name' },
values: { $push: { goal_value: '$goal_value', user_count: '$user_count' } },
},
},
{
$group: {
_id: '$_id.month',
goals: { $push: { k: '$_id.goal_name', v: '$values' } },
},
},
{
$project: {
_id: 0,
k: '$_id',
v: {
$arrayToObject: {
$filter: {
input: '$goals',
as: 'item',
cond: { $and: [{ $ne: ['$$item.k', null] }, { $ne: ['$$item.v', null] }] },
},
},
},
},
},
{ $group: { _id: null, entries: { $push: { k: '$k', v: '$v' } } } },
{ $replaceRoot: { newRoot: { $arrayToObject: '$entries' } } },
];
}
export function withHierarchy(match, hierarchyIds) {
if (!hierarchyIds) return match;
return { ...match, hierarchy_id: { $in: hierarchyIds } };
}
export function actionRequest(report, hierarchyIds) {
const match = withHierarchy(report.match, hierarchyIds);
return {
partition: report.request.partition,
form_id: report.request.formId,
pipeline: actionPipeline(match),
};
}
export function goalRequest(report, hierarchyIds) {
const match = withHierarchy(report.match, hierarchyIds);
return {
partition: report.request.partition,
form_id: report.request.formId,
pipeline: goalPipeline(match, report.goals),
};
}
export function submissionList(response) {
const submissions = response?.data?.submissions;
if (Array.isArray(submissions)) return submissions;
if (submissions && typeof submissions === 'object') return [submissions];
return [];
}
export function actionRows(submissions, plans) {
const slugToLabel = new Map();
Object.entries(plans).forEach(([label, slugs]) => {
slugs.forEach((slug) => slugToLabel.set(slug, label));
});
const rows = new Map();
submissions.forEach((submission) => {
const label = slugToLabel.get(slugify(submission?.action_plan_label));
if (!label) return;
if (!rows.has(label)) {
const row = { label };
ACTION_COLUMNS.forEach((key) => {
row[key] = isNumeric(submission[key]) ? Number(submission[key]) : 0;
});
rows.set(label, row);
return;
}
const row = rows.get(label);
ACTION_COLUMNS.forEach((key) => {
if (isNumeric(row[key]) && isNumeric(submission[key])) row[key] = Number(row[key]) + Number(submission[key]);
});
});
return [...rows.values()];
}
function addGoalCount(byMonth, month, goalName, goalValue, userCount) {
if (!month || !goalName) return;
if (!byMonth[month]) byMonth[month] = {};
if (!Array.isArray(byMonth[month][goalName])) byMonth[month][goalName] = [];
byMonth[month][goalName].push({ goal_value: goalValue, user_count: Number(userCount) || 0 });
}
export function goalGrid(submissions, goals) {
const byMonth = {};
const flat = submissions.length > 0 && submissions.every((item) => item && item.month && item.goal_name);
submissions.forEach((item) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) return;
if (flat) {
addGoalCount(byMonth, item.month, item.goal_name, item.goal_value, item.user_count);
return;
}
Object.entries(item).forEach(([month, payload]) => {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return;
byMonth[month] = payload;
});
});
const months = Object.keys(byMonth).sort();
return { months, byMonth, goals: goals.map((goal) => goal.name) };
}
export function goalCount(byMonth, month, goalName, label) {
const rows = byMonth[month]?.[goalName];
if (!Array.isArray(rows)) return 0;
return rows.reduce((sum, row) => sum + (row?.goal_value === label ? Number(row.user_count) || 0 : 0), 0);
}
export function monthHeading(month) {
if (!/^\d{4}-\d{2}$/.test(month)) return month;
const [year, mon] = month.split('-');
const label = new Date(Number(year), Number(mon) - 1, 1).toLocaleString('en-US', { month: 'short' });
return `${label}-${year.slice(2)}`;
}

View File

@ -0,0 +1,56 @@
import { useCallback } from 'react';
import { formatNumber } from './helpers';
import { DataTableBottom, DataTableTop, useDataTable } from './useDataTable';
export default function DrBrandWise({ table }) {
const columns = table.brandColumns;
const cellsOf = useCallback((row) => [
{ raw: row.drName, display: String(row.drName ?? '') },
{ raw: row.total ?? 0, display: formatNumber(row.total ?? 0) },
...columns.map((col) => ({ raw: row.values[col.key] ?? 0, display: formatNumber(row.values[col.key] ?? 0) })),
], [columns]);
const data = useDataTable(table.rows, cellsOf);
const paged = table.rows.length > 0;
const headers = ['Dr Name', 'Total Brand POB Val', ...columns.map((col) => col.key)];
const colCount = 2 + columns.length;
const shown = paged ? data.visibleRows : [];
return (
<>
{paged ? <DataTableTop table={data} /> : null}
<div className="iva-report-scroll">
<table className="table table-centered table-nowrap mb-0 iva-report-table w-100">
<thead>
<tr>
{headers.map((header, index) => (
<th
key={header}
className={`${index === 0 ? 'iva-th-id' : 'iva-th-metric'}${paged ? ` ${data.sortClass(index)}` : ''}`}
onClick={paged ? () => data.toggleSort(index) : undefined}
>
{header}
</th>
))}
</tr>
</thead>
<tbody>
{shown.length ? shown.map((row, rowIndex) => (
<tr key={rowIndex}>
{cellsOf(row).map((cell, index) => (
<td key={index}>{cell.display}</td>
))}
</tr>
)) : (
<tr>
<td colSpan={Math.max(1, colCount)} className="text-center text-muted py-3">
{paged ? 'No matching records found' : 'No data'}
</td>
</tr>
)}
</tbody>
</table>
</div>
{paged ? <DataTableBottom table={data} /> : null}
</>
);
}

View File

@ -0,0 +1,35 @@
import { formatNumber } from './helpers';
export default function KamWise({ table }) {
const columns = table.brandColumns;
return (
<table className="table table-centered table-nowrap mb-0 iva-report-table">
<thead>
<tr>
<th className="sticky-first-col iva-th-id">Territory</th>
<th className="iva-th-id">KAM Name</th>
<th className="iva-th-metric">Total Brand POB Val</th>
{columns.map((col) => (
<th key={col.key} className="iva-th-metric">{col.key}</th>
))}
</tr>
</thead>
<tbody>
{table.rows.length ? table.rows.map((row, rowIndex) => (
<tr key={rowIndex}>
<td className="sticky-first-col">{row.territory}</td>
<td>{row.kamName}</td>
<td>{formatNumber(row.total ?? 0)}</td>
{columns.map((col) => (
<td key={col.key}>{formatNumber(row.values[col.key] ?? 0)}</td>
))}
</tr>
)) : (
<tr>
<td colSpan={Math.max(1, 3 + columns.length)} className="text-center text-muted py-3">No data</td>
</tr>
)}
</tbody>
</table>
);
}

View File

@ -0,0 +1,74 @@
import { useMemo, useState } from 'react';
import { Button, Dropdown, Form } from 'react-bootstrap';
export default function MultiSelectFilter({ label, options, selected, onApply, disabled, searchable, emptyText }) {
const [show, setShow] = useState(false);
const [draft, setDraft] = useState(selected);
const [search, setSearch] = useState('');
const visible = useMemo(() => {
const term = search.trim().toLowerCase();
if (!term) return options;
return options.filter((option) => option.label.toLowerCase().includes(term));
}, [options, search]);
const toggleOpen = (next) => {
if (next) {
setDraft(selected);
setSearch('');
}
setShow(next);
};
const toggleOption = (value) => {
setDraft((prev) => (prev.includes(value) ? prev.filter((item) => item !== value) : [...prev, value]));
};
const apply = () => {
onApply(options.map((option) => option.value).filter((value) => draft.includes(value)));
setShow(false);
};
const count = show ? draft.length : selected.length;
return (
<Dropdown show={show} onToggle={toggleOpen} autoClose="outside" align="end">
<Dropdown.Toggle variant="outline-secondary" className="form-select iva-filter-toggle" disabled={disabled}>
{count ? `${label} (${count})` : label}
</Dropdown.Toggle>
<Dropdown.Menu className="p-3 iva-filter-menu">
<div className="iva-filter-options">
{searchable ? (
<div className="mb-2">
<Form.Control
size="sm"
placeholder="Search hierarchy..."
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
</div>
) : null}
{options.length ? (
visible.map((option) => (
<Form.Check
key={option.value}
id={`iva-${label}-${option.value}`}
type="checkbox"
label={option.label}
checked={draft.includes(option.value)}
onChange={() => toggleOption(option.value)}
/>
))
) : (
<div className="text-muted small">{emptyText}</div>
)}
</div>
<hr className="my-2" />
<div className="d-flex justify-content-between">
<Button size="sm" variant="light" onClick={() => setDraft([])}>Clear</Button>
<Button size="sm" variant="primary" onClick={apply}>Apply</Button>
</div>
</Dropdown.Menu>
</Dropdown>
);
}

View File

@ -0,0 +1,44 @@
import { formatNumber } from './helpers';
export default function Planner({ table }) {
const groups = table.brandGroups;
const colCount = 2 + groups.reduce((sum, group) => sum + group.columns.length, 0);
return (
<table className="table table-centered table-nowrap mb-0 iva-report-table">
<thead>
<tr>
<th rowSpan={2} className="sticky-first-col iva-th-id">Territory</th>
<th rowSpan={2} className="iva-th-id">KAM Name</th>
{groups.map((group) => (
<th key={group.name} colSpan={group.columns.length} className={`text-center iva-group-start ${group.style.className}`}>{group.name}</th>
))}
</tr>
<tr>
{groups.flatMap((group) => group.columns.map((col, index) => (
<th key={`${group.name}-${col.key}`} className={`iva-th-metric${index === 0 ? ' iva-group-start' : ''}`}>{col.label}</th>
)))}
</tr>
</thead>
<tbody>
{table.rows.length ? table.rows.map((row, rowIndex) => (
<tr key={rowIndex}>
<td className="sticky-first-col">{row.territory}</td>
<td>{row.kamName}</td>
{groups.map((group) => {
const brand = row.brands[group.name] ?? {};
return [
<td key={`${group.name}-plan`} className="iva-group-start">{formatNumber(brand.plan ?? 0)}</td>,
<td key={`${group.name}-done`}>{formatNumber(brand.done ?? 0)}</td>,
<td key={`${group.name}-pct`}>{formatNumber(brand.pct ?? 0, true)}</td>,
];
})}
</tr>
)) : (
<tr>
<td colSpan={Math.max(1, colCount)} className="text-center text-muted py-3">No data</td>
</tr>
)}
</tbody>
</table>
);
}

View File

@ -0,0 +1,201 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Container, Form, Nav, Spinner } from 'react-bootstrap';
import { Link, useSearchParams } from 'react-router';
import { isSuccess, signedGet } from '@/api/client';
import { useAuth } from '@/auth/AuthProvider';
import PageMetaData from '@/components/PageMetaData';
import DrBrandWise from './DrBrandWise';
import KamWise from './KamWise';
import MultiSelectFilter from './MultiSelectFilter';
import Planner from './Planner';
import SkuWise from './SkuWise';
import { exportIvaReport } from './exportXlsx';
import {
PARTITION,
REPORT_TIMEOUT,
TABS,
availableMonths,
brandOptions,
buildTable,
filterQuery,
monthSlug,
resolveFilters,
} from './helpers';
const EMPTY_PAYLOAD = { report: [], metricConfig: [] };
const VIEWS = {
planner: Planner,
kamwise: KamWise,
drbrandwise: DrBrandWise,
skuwise: SkuWise,
};
export default function IvaReportPage({ tab }) {
const config = TABS.find((item) => item.id === tab);
const { domain, hasPermission } = useAuth();
const allowed = domain === 'iva';
const allDataAccess = hasPermission('all_data_access');
const [searchParams, setSearchParams] = useSearchParams();
const filters = useMemo(() => resolveFilters(searchParams, config.reportType), [searchParams, config.reportType]);
const query = filterQuery(filters);
const months = useMemo(() => availableMonths(), []);
const brands = useMemo(
() => Object.entries(brandOptions(config.reportType)).map(([value, label]) => ({ value, label })),
[config.reportType],
);
const [hierarchies, setHierarchies] = useState([]);
const [loading, setLoading] = useState(allowed);
const [error, setError] = useState('');
const [payload, setPayload] = useState(EMPTY_PAYLOAD);
const [exporting, setExporting] = useState(false);
useEffect(() => {
if (!allowed) return;
const params = { partition: PARTITION };
if (allDataAccess) params.hierarchy_id = 'root';
signedGet('ums', 'hierarchy/hierarchy_below_me', params).then((response) => {
if (!isSuccess(response)) return;
const list = Array.isArray(response.data?.hierarchies) ? [...response.data.hierarchies] : [];
list.sort((a, b) => Number(b.level || 0) - Number(a.level || 0));
setHierarchies(list);
});
}, [allowed, allDataAccess]);
const hierarchyKey = JSON.stringify(filters.hierarchyIds);
const brandKey = JSON.stringify(filters.selectedBrands);
useEffect(() => {
if (!allowed) return undefined;
let cancelled = false;
setLoading(true);
setError('');
signedGet('reports', 'iva_reports/brand_reports', {
report_type: config.reportType,
selected_brand: brandKey,
month: filters.month,
hierarchy_ids: hierarchyKey,
}, { timeout: REPORT_TIMEOUT }).then((response) => {
if (cancelled) return;
if (!response || response.status !== 'success') {
setPayload(EMPTY_PAYLOAD);
setError(response?.message || 'Unable to load report. Please try again.');
return;
}
setPayload({
report: response.data?.report ?? [],
metricConfig: response.data?.metric_config ?? [],
});
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [allowed, config.reportType, filters.month, hierarchyKey, brandKey]);
const table = useMemo(() => buildTable(tab, payload, filters.selectedBrands), [tab, payload, filters.selectedBrands]);
const hierarchyOptions = useMemo(
() => hierarchies.map((item) => ({ value: item.id, label: `${item.name}${item.type ? ` (${item.type})` : ''}` })),
[hierarchies],
);
const applyFilters = (next) => {
setSearchParams(filterQuery({ ...filters, ...next }));
};
const handleExport = async () => {
setExporting(true);
try {
await exportIvaReport(tab, table, `${config.file}-${monthSlug(filters.month)}.xlsx`);
} catch (err) {
setError(err.message || 'Unable to export report.');
} finally {
setExporting(false);
}
};
if (!allowed) {
return (
<Container fluid>
<PageMetaData title={config.label} />
<div className="alert alert-danger mt-3">You do not have access to this report.</div>
</Container>
);
}
const View = VIEWS[tab];
const scrolls = tab === 'planner' || tab === 'kamwise';
return (
<Container fluid>
<PageMetaData title={config.label} />
<div className="row">
<div className="col-md-4 p-2">
<h4 className="page-title">{config.label}</h4>
</div>
<div className="col-md-8 p-2 d-flex justify-content-end align-items-center">
<div className="d-flex flex-wrap align-items-center justify-content-end gap-2">
<MultiSelectFilter
label="Hierarchy"
options={hierarchyOptions}
selected={filters.hierarchyIds}
onApply={(ids) => applyFilters({ hierarchyIds: ids })}
searchable
emptyText="No hierarchy available"
/>
<MultiSelectFilter
label="Brand"
options={brands}
selected={filters.selectedBrands}
onApply={(values) => applyFilters({ selectedBrands: values })}
/>
<Form.Select style={{ width: 170 }} value={filters.month} onChange={(event) => applyFilters({ month: event.target.value })}>
{months.map((month) => (
<option key={month.value} value={month.value}>{month.label}</option>
))}
</Form.Select>
</div>
</div>
</div>
{error ? <div className="alert alert-danger">{error}</div> : null}
<Nav variant="tabs" className="mb-3">
{TABS.map((item) => (
<Nav.Item key={item.id}>
<Nav.Link as={Link} to={`${item.url}?${query}`} active={item.id === tab}>{item.label}</Nav.Link>
</Nav.Item>
))}
</Nav>
<div className="row">
<div className="col-12">
<div className="card">
<div className="card-header d-flex justify-content-between align-items-center">
<h5 className="card-title mb-0">{config.label}</h5>
<Button size="sm" variant="primary" onClick={handleExport} disabled={loading || exporting}>
{exporting ? 'Exporting...' : 'Export'}
</Button>
</div>
<div className="card-body position-relative">
{loading ? (
<div className="iva-loading">
<Spinner animation="border" />
</div>
) : null}
{scrolls ? (
<div className="iva-report-scroll">
<View table={table} />
</div>
) : (
<View key={`${tab}-${query}`} table={table} />
)}
</div>
</div>
</div>
</div>
</Container>
);
}

View File

@ -0,0 +1,69 @@
import { useCallback } from 'react';
import { formatNumber } from './helpers';
import { DataTableBottom, DataTableTop, useDataTable } from './useDataTable';
export default function SkuWise({ table }) {
const groups = table.skuGroups;
const skuColumns = groups.flatMap((group) => group.columns.map((col, index) => ({ ...col, groupStart: index === 0 })));
const cellsOf = useCallback((row) => [
{ raw: row.drName, display: String(row.drName ?? '') },
...groups.flatMap((group) => group.columns.map((col) => ({
raw: row.values[col.key] ?? 0,
display: formatNumber(row.values[col.key] ?? 0, true),
}))),
], [groups]);
const data = useDataTable(table.rows, cellsOf);
const paged = table.rows.length > 0;
const colCount = 1 + skuColumns.length;
const shown = paged ? data.visibleRows : [];
const sortable = (index) => (paged ? { className: data.sortClass(index), onClick: () => data.toggleSort(index) } : { className: '' });
return (
<>
{paged ? <DataTableTop table={data} /> : null}
<div className="iva-report-scroll">
<table className="table table-centered table-nowrap mb-0 iva-report-table w-100">
<thead>
<tr>
<th rowSpan={2} className={`sticky-first-col iva-th-id ${sortable(0).className}`} onClick={sortable(0).onClick}>Dr Name</th>
{groups.map((group) => (
<th key={group.name} colSpan={group.columns.length} className={`text-center iva-group-start ${group.style.className}`}>{group.name}</th>
))}
</tr>
<tr>
{skuColumns.map((col, index) => {
const sort = sortable(index + 1);
return (
<th key={col.key} className={`iva-th-metric${col.groupStart ? ' iva-group-start' : ''} ${sort.className}`} onClick={sort.onClick}>
{col.key}
</th>
);
})}
</tr>
</thead>
<tbody>
{shown.length ? shown.map((row, rowIndex) => (
<tr key={rowIndex}>
{cellsOf(row).map((cell, index) => (
<td
key={index}
className={index === 0 ? 'sticky-first-col' : skuColumns[index - 1].groupStart ? 'iva-group-start' : undefined}
>
{cell.display}
</td>
))}
</tr>
)) : (
<tr>
<td colSpan={Math.max(1, colCount)} className="text-center text-muted py-3">
{paged ? 'No matching records found' : 'No data'}
</td>
</tr>
)}
</tbody>
</table>
</div>
{paged ? <DataTableBottom table={data} /> : null}
</>
);
}

View File

@ -0,0 +1,243 @@
import { COLOR_ID, COLOR_META, COLOR_METRIC, COLOR_SKU, excelNumber, metricsByAttribute } from './helpers';
function excelCol(index) {
let letter = '';
let value = index;
while (value > 0) {
value -= 1;
letter = String.fromCharCode(65 + (value % 26)) + letter;
value = Math.floor(value / 26);
}
return letter;
}
function colIndex(letters) {
return letters.split('').reduce((sum, char) => sum * 26 + (char.charCodeAt(0) - 64), 0);
}
function parseCell(ref) {
const match = /^([A-Z]+)(\d+)$/.exec(ref);
return { col: colIndex(match[1]), row: Number(match[2]) };
}
function parseRange(range) {
const [from, to = from] = range.split(':');
const start = parseCell(from);
const end = parseCell(to);
return { top: start.row, left: start.col, bottom: end.row, right: end.col };
}
// Laravel Excel (strict_null_comparison false) skips values loosely equal to null, so 0 and '' stay blank.
// PhpSpreadsheet's default binder stores numeric text as numbers, except values with a leading zero.
function cellValue(value) {
if (value === null || value === undefined || value === '' || value === 0 || value === false) return null;
if (typeof value !== 'string' || !/^[+-]?(\d+\.?\d*|\d*\.?\d+)([Ee][-+]?[0-2]?\d{1,3})?$/.test(value)) return value;
const digits = value.replace(/^[+-]+/, '');
if (digits.length > 1 && digits[0] === '0' && digits[1] !== '.') return value;
if (!value.includes('.') && !Number.isSafeInteger(Number(value))) return value;
return Number(value);
}
function plannerExport(table) {
const { brandGroups, skuColumns, rows } = table;
const row1 = ['Territory', 'KAM Emp Code', 'KAM Name'];
const row2 = ['', '', ''];
const merges = ['A1:A2', 'B1:B2', 'C1:C2'];
const fills = [['A1:C2', COLOR_ID]];
let col = 4;
brandGroups.forEach((group) => {
const skus = skuColumns[group.name] ?? [];
const span = 3 + skus.length * 3;
const start = excelCol(col);
const end = excelCol(col + span - 1);
merges.push(`${start}1:${end}1`);
fills.push([`${start}1:${end}1`, group.style]);
fills.push([`${start}2:${end}2`, COLOR_METRIC]);
row1.push(group.name, ...Array(span - 1).fill(''));
row2.push('POB Plan (Val)', 'POB Done (Val)', '% POB');
skus.forEach((sku) => row2.push(sku.planAttr, sku.doneAttr, sku.pctLabel));
col += span;
});
const data = [row1, row2];
rows.forEach((row) => {
const line = [row.territory, row.kamEmpCode, row.kamName];
brandGroups.forEach((group) => {
const brand = row.brands[group.name] ?? {};
line.push(excelNumber(brand.plan ?? 0), excelNumber(brand.done ?? 0), excelNumber(brand.pct ?? 0, true));
const planMap = metricsByAttribute(brand.planMetrics);
const doneMap = metricsByAttribute(brand.doneMetrics);
(skuColumns[group.name] ?? []).forEach((sku) => {
const planVal = planMap[sku.planAttr] ?? 0;
const doneVal = doneMap[sku.doneAttr] ?? 0;
const pct = Number(planVal) > 0 ? Math.round((Number(doneVal) / Number(planVal)) * 100 * 100) / 100 : 0;
line.push(excelNumber(planVal), excelNumber(doneVal), excelNumber(pct, true));
});
});
data.push(line);
});
return { data, merges, fills, headerRows: 2, freeze: 'D3' };
}
function kamExport(table) {
const { brandColumns, skuColumns, rows } = table;
const headers = ['Territory', 'KAM Emp Code', 'KAM Name', 'Total Brand POB Val'];
const fills = [['A1:C1', COLOR_ID], ['D1:D1', COLOR_METRIC]];
let col = 5;
brandColumns.forEach(({ key }) => {
headers.push(key);
fills.push([`${excelCol(col)}1`, COLOR_METRIC]);
col += 1;
(skuColumns[key] ?? []).forEach((attr) => {
headers.push(attr);
fills.push([`${excelCol(col)}1`, COLOR_SKU]);
col += 1;
});
});
const data = [headers];
rows.forEach((row) => {
const line = [row.territory, row.kamEmpCode, row.kamName, excelNumber(row.total ?? 0)];
brandColumns.forEach(({ key }) => {
line.push(excelNumber(row.values[key] ?? 0));
const metricMap = metricsByAttribute(row.metrics[key]);
(skuColumns[key] ?? []).forEach((attr) => line.push(excelNumber(metricMap[attr] ?? 0)));
});
data.push(line);
});
return { data, merges: [], fills, headerRows: 1, freeze: 'D2' };
}
function drBrandExport(table) {
const { brandColumns, rows } = table;
const headers = ['Dr Name', 'Dr UID', 'Dr SBU', 'Total Brand POB Val', ...brandColumns.map((col) => col.key)];
const fills = [['A1:A1', COLOR_ID], ['B1:C1', COLOR_META], [`D1:${excelCol(headers.length)}1`, COLOR_METRIC]];
const data = [headers];
rows.forEach((row) => {
data.push([
row.drName,
row.drUid,
row.drSbu,
excelNumber(row.total ?? 0),
...brandColumns.map((col) => excelNumber(row.values[col.key] ?? 0)),
]);
});
return { data, merges: [], fills, headerRows: 1, freeze: 'B2' };
}
function skuExport(table) {
const { skuGroups, rows } = table;
const row1 = ['Dr Name'];
const row2 = [''];
const merges = ['A1:A2'];
const fills = [['A1:A2', COLOR_ID]];
let col = 2;
skuGroups.forEach((group) => {
const span = Math.max(1, group.columns.length);
const start = excelCol(col);
const end = excelCol(col + span - 1);
merges.push(`${start}1:${end}1`);
fills.push([`${start}1:${end}1`, group.style ?? COLOR_METRIC]);
fills.push([`${start}2:${end}2`, COLOR_METRIC]);
row1.push(group.name, ...Array(span - 1).fill(''));
group.columns.forEach((sku) => row2.push(sku.key));
col += span;
});
const data = [row1, row2];
rows.forEach((row) => {
const line = [row.drName];
skuGroups.forEach((group) => group.columns.forEach((sku) => line.push(excelNumber(row.values[sku.key] ?? 0, true))));
data.push(line);
});
return { data, merges, fills, headerRows: 2, freeze: 'B3' };
}
const BUILDERS = {
planner: plannerExport,
kamwise: kamExport,
drbrandwise: drBrandExport,
skuwise: skuExport,
};
function eachInRange(sheet, range, visit) {
for (let row = range.top; row <= range.bottom; row += 1) {
for (let col = range.left; col <= range.right; col += 1) visit(sheet.getCell(row, col));
}
}
// Approximates Maatwebsite ShouldAutoSize; cells merged across columns are skipped like PhpSpreadsheet.
function autoSize(sheet, data, merges, colCount) {
const merged = new Set();
merges.map(parseRange).forEach((range) => {
if (range.right === range.left) return;
for (let row = range.top; row <= range.bottom; row += 1) {
for (let col = range.left; col <= range.right; col += 1) merged.add(`${row}:${col}`);
}
});
for (let col = 1; col <= colCount; col += 1) {
let longest = 0;
data.forEach((line, index) => {
if (merged.has(`${index + 1}:${col}`)) return;
const value = line[col - 1];
if (value === null || value === undefined || value === '') return;
const length = Math.max(...String(value).split('\n').map((part) => part.length));
if (length > longest) longest = length;
});
sheet.getColumn(col).width = Math.max(Math.ceil(longest * 1.1) + 2, 8);
}
}
function freezeView(ref) {
const { col, row } = parseCell(ref);
return [{ state: 'frozen', xSplit: col - 1, ySplit: row - 1 }];
}
export async function exportIvaReport(tab, table, filename) {
const { data, merges, fills, headerRows, freeze } = BUILDERS[tab](table);
const { default: ExcelJS } = await import('exceljs');
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Worksheet');
const values = data.map((line) => line.map(cellValue));
values.forEach((line) => sheet.addRow(line));
const rowCount = Math.max(1, data.length);
const colCount = Math.max(1, ...data.map((line) => line.length));
autoSize(sheet, values, merges, colCount);
merges.forEach((range) => sheet.mergeCells(range));
const thin = { style: 'thin', color: { argb: 'FFB0B0B0' } };
eachInRange(sheet, { top: 1, left: 1, bottom: rowCount, right: colCount }, (cell) => {
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
cell.border = { top: thin, left: thin, bottom: thin, right: thin };
cell.font = { name: 'Calibri', size: 11 };
});
if (headerRows > 0) {
eachInRange(sheet, { top: 1, left: 1, bottom: headerRows, right: colCount }, (cell) => {
cell.font = { ...cell.font, bold: true };
});
sheet.getRow(1).height = 22;
if (headerRows > 1) sheet.getRow(2).height = 30;
}
fills.forEach(([range, color]) => {
eachInRange(sheet, parseRange(range), (cell) => {
cell.font = { ...cell.font, bold: true, color: { argb: `FF${color.fg ?? '000000'}` } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: `FF${color.bg ?? 'FFFFFF'}` } };
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
});
});
sheet.views = freezeView(freeze);
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const anchor = document.createElement('a');
anchor.href = URL.createObjectURL(blob);
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(anchor.href);
}

View File

@ -0,0 +1,373 @@
export const REPORT_TIMEOUT = 120000;
export const PARTITION = 'rapport-iva';
const REPORT_START = new Date(2026, 3, 1);
export const TABS = [
{ id: 'planner', label: 'Planner Data', url: '/ivareport/planner', reportType: 'planner_data', file: 'planner-data' },
{ id: 'kamwise', label: 'KAM wise details', url: '/ivareport/kamwise', reportType: 'kam_wise', file: 'kam-wise-details' },
{ id: 'drbrandwise', label: 'Dr & Brand Wise details', url: '/ivareport/drbrandwise', reportType: 'dr_and_brandwise', file: 'dr-brand-wise-details' },
{ id: 'skuwise', label: 'SKU wise Rate', url: '/ivareport/skuwise', reportType: 'sku_wise', file: 'sku-wise-rate' },
];
export const PLANNER_BRANDS = {
'Materna HMG': 'Materna HMG',
Ciscure: 'Ciscure',
Emprogest: 'Emprogest',
};
export const KAM_BRANDS = {
'Materna HMG POB Val': 'Materna HMG',
'Materna HCG POB Val': 'Materna HCG',
'Materna Depot POB Val': 'Materna Depot',
'Ciscure POB Val': 'Ciscure',
'Emprogest POB Val': 'Emprogest',
'Dydroeva POB Val': 'Dydroeva',
'Exhep POB Val': 'Exhep',
'Emgra POB Val': 'Emgra',
'Silnafil POB Val': 'Silnafil',
'Xgrast POB Val': 'Xgrast',
'Orofer POB Val': 'Orofer',
};
export const SKU_BRANDS = {
CISCURE: 'CISCURE',
DYDROEVA: 'DYDROEVA',
EMGRA: 'EMGRA',
EMPROGEST: 'EMPROGEST',
EXHEP: 'EXHEP',
'MATERNA DEPOT': 'MATERNA DEPOT',
'MATERNA HCG': 'MATERNA HCG',
MATERNA: 'MATERNA',
OROFER: 'OROFER',
SILNAFIL: 'SILNAFIL',
XGRAST: 'XGRAST',
};
const SKU_GROUPS = {
CISCURE: ['CISCURE PFS Rate', 'CISCURE 0.25 MG INJECTION Rate'],
DYDROEVA: ['DYDROEVA TABLET 10 MG Rate'],
EMGRA: ['EMGRA TABLET 10X28T Rate'],
EMPROGEST: [
'EMPROGEST - 200 CAPSULE Rate',
'EMPROGEST - 400 CAPSULE Rate',
'EMPROGEST EV 200 TABLET Rate',
'EMPROGEST GEL 1.125 GM Rate',
'EMPROGEST SR 200 TABLET Rate',
'EMPROGEST SR 300 TABLET Rate',
],
EXHEP: [
'EXHEP 20 INJECTION Rate',
'EXHEP 40 MG INJECTION 10PFS Rate',
'EXHEP 60 MG INJECTION 10PFS Rate',
'EXHEP PEN CARTRIDGE 300MG/3ML Rate',
],
'MATERNA DEPOT': ['MATERNA DEPOT 3.75 MG INJECTION Rate'],
'MATERNA HCG': ['MATERNA-HCG 10000 IU Rate', 'MATERNA-HCG 5000 IU Rate'],
MATERNA: [
'MATERNA-HMG 150 I.U. Rate',
'MATERNA-HMG 75 I.U. Rate',
'MATERNA-HMG HP 1200 IU Rate',
'MATERNA-HMG HP 225 IU Rate',
'MATERNA-HMG HP 300 IU Rate',
],
OROFER: ['OROFER S 100 INJECTION Rate', 'OROFER S 200 INJECTION 10 ML Rate', 'OROFER S INJECTION 2.5 ML Rate'],
SILNAFIL: ['SILNAFIL TABLETS Rate'],
XGRAST: ['XGRAST INJ 300 MCG/0.5 ML PFS Rate'],
};
export const COLOR_ID = { bg: '00B050', fg: '000000' };
export const COLOR_METRIC = { bg: 'FFFF00', fg: '000000' };
export const COLOR_META = { bg: 'E7E6E6', fg: '000000' };
export const COLOR_SKU = { bg: 'FFC000', fg: '000000' };
const PLANNER_BRAND_COLORS = {
'Materna HMG': { bg: 'E7E6E6', fg: '000000' },
Ciscure: { bg: 'F8CBAD', fg: '000000' },
Emprogest: { bg: 'DEEBF7', fg: '000000' },
};
const SKU_BRAND_COLORS = {
CISCURE: { bg: 'ED7D31', fg: 'FFFFFF' },
DYDROEVA: { bg: '3D4C5F', fg: 'FFFFFF' },
EMGRA: { bg: 'B4C7E7', fg: '000000' },
EMPROGEST: { bg: '548235', fg: 'FFFFFF' },
EXHEP: { bg: 'DEEBF7', fg: '000000' },
'MATERNA DEPOT': { bg: 'FFC000', fg: '000000' },
'MATERNA HCG': { bg: 'FFE699', fg: '000000' },
MATERNA: { bg: 'E2EFDA', fg: '000000' },
OROFER: { bg: 'BDD7EE', fg: '000000' },
SILNAFIL: { bg: 'B25E25', fg: 'FFFFFF' },
XGRAST: { bg: 'A9CE91', fg: '000000' },
};
function monthLabel(date) {
return date.toLocaleString('en-US', { month: 'long', year: 'numeric' });
}
export function currentMonth() {
return monthLabel(new Date());
}
export function availableMonths(now = new Date()) {
const months = [{ value: 'overall', label: 'Overall' }];
const end = new Date(now.getFullYear(), now.getMonth(), 1);
const start = REPORT_START > end ? end : REPORT_START;
for (let cursor = end; cursor >= start; cursor = new Date(cursor.getFullYear(), cursor.getMonth() - 1, 1)) {
const label = monthLabel(cursor);
months.push({ value: label, label });
}
return months;
}
export function brandOptions(reportType) {
if (reportType === 'planner_data') return PLANNER_BRANDS;
if (reportType === 'sku_wise') return SKU_BRANDS;
return KAM_BRANDS;
}
export function resolveFilters(searchParams, reportType) {
const months = availableMonths();
let month = String(searchParams.get('month') ?? currentMonth()).trim();
if (!months.some((item) => item.value === month)) month = currentMonth();
const hierarchyIds = searchParams.getAll('filter_hier').filter(Boolean);
const allowed = Object.keys(brandOptions(reportType));
const selectedBrands = searchParams.getAll('selected_brand').filter((brand) => brand && allowed.includes(brand));
return { month, hierarchyIds, selectedBrands };
}
export function filterQuery(filters) {
const params = new URLSearchParams();
params.set('month', filters.month);
filters.hierarchyIds.forEach((id) => params.append('filter_hier', id));
filters.selectedBrands.forEach((brand) => params.append('selected_brand', brand));
return params.toString();
}
export function monthSlug(month) {
return String(month).replace(/[^A-Za-z0-9]+/g, '-');
}
export function isNumeric(value) {
if (typeof value === 'number') return Number.isFinite(value);
if (typeof value !== 'string' || value.trim() === '') return false;
return Number.isFinite(Number(value));
}
function numberFormat(num, decimals) {
return num.toLocaleString('en-US', { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
}
export function formatNumber(val, isPct = false) {
if (val === null || val === undefined || val === '') return '';
if (!isNumeric(val)) return String(val);
const num = Number(val);
if (isPct) return num === 0 ? '0' : numberFormat(num, 2);
if (Math.floor(num) === num) return numberFormat(num, 0);
return numberFormat(num, 2);
}
function round2(num) {
return Math.round((num + Number.EPSILON) * 100) / 100;
}
export function excelNumber(val, isPct = false) {
if (val === null || val === undefined || val === '' || !isNumeric(val)) return val ?? 0;
const num = Number(val);
if (isPct) return round2(num);
return Math.floor(num) === num ? num : round2(num);
}
function slug(name) {
return String(name).replace(/[^a-z0-9]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase();
}
function brandStyle(map, name, prefix) {
const style = map[name] ?? { bg: 'D9E2F3', fg: '000000' };
return { ...style, className: `${prefix}${slug(name)}` };
}
function visibleBrandKeys(all, selected) {
const keys = Object.keys(all);
return selected.length ? keys.filter((key) => selected.includes(key)) : keys;
}
function asObject(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
}
function asList(value) {
if (Array.isArray(value)) return value;
if (value && typeof value === 'object') return Object.values(value);
return [];
}
function normalizeMetrics(metrics) {
return asList(metrics).map((metric) => ({
attribute: metric?.attribute ?? '',
units: metric?.units ?? 0,
}));
}
export function metricsByAttribute(metrics) {
const map = {};
asList(metrics).forEach((metric) => {
const attr = metric?.attribute ?? '';
if (attr === '') return;
map[attr] = metric?.units ?? 0;
});
return map;
}
function skuColumnsFromPlanDone(plans, dones) {
const planBySku = {};
const skus = [];
asList(plans).forEach((item) => {
const attr = item?.attribute ?? '';
if (attr === '') return;
const sku = attr.replace(/ POB Plan Val$/, '');
if (!(sku in planBySku)) planBySku[sku] = attr;
if (!skus.includes(sku)) skus.push(sku);
});
const doneBySku = {};
asList(dones).forEach((item) => {
const attr = item?.attribute ?? '';
if (attr === '') return;
const sku = attr.replace(/ POB Done Val$/, '');
if (!(sku in doneBySku)) doneBySku[sku] = attr;
if (!skus.includes(sku)) skus.push(sku);
});
return skus.map((sku) => ({
sku,
planAttr: planBySku[sku] ?? `${sku} POB Plan Val`,
doneAttr: doneBySku[sku] ?? `${sku} POB Done Val`,
pctLabel: `${sku} POB %`,
}));
}
function plannerSkuColumns(metricConfig, brandKeys, rows) {
const byBrand = {};
asList(metricConfig).forEach((entry) => {
Object.entries(asObject(entry)).forEach(([brand, cfg]) => {
if (!brandKeys.includes(brand)) return;
const config = asObject(cfg);
byBrand[brand] = skuColumnsFromPlanDone(config['POB Plan (Val)'], config['POB Done (Val)']);
});
});
brandKeys.forEach((brand) => {
if (byBrand[brand]?.length) return;
const planMetrics = rows.flatMap((row) => row.brands[brand]?.planMetrics ?? []);
const doneMetrics = rows.flatMap((row) => row.brands[brand]?.doneMetrics ?? []);
byBrand[brand] = skuColumnsFromPlanDone(planMetrics, doneMetrics);
});
return byBrand;
}
export function buildPlannerTable(report, metricConfig, selectedBrands) {
const brandKeys = visibleBrandKeys(PLANNER_BRANDS, selectedBrands);
const brandGroups = brandKeys.map((brand) => ({
name: brand,
style: brandStyle(PLANNER_BRAND_COLORS, brand, 'iva-planner-'),
columns: [
{ key: 'plan', label: 'POB Plan (Val)' },
{ key: 'done', label: 'POB Done (Val)' },
{ key: 'pct', label: '% POB' },
],
}));
const rows = asList(report).map((raw) => {
const item = asObject(raw);
const brands = {};
brandKeys.forEach((brand) => {
const data = asObject(item[brand]);
const metrics = asObject(data.metrics);
brands[brand] = {
plan: data['POB Plan (Val)'] ?? 0,
done: data['POB Done (Val)'] ?? 0,
pct: data['% POB'] ?? 0,
planMetrics: normalizeMetrics(metrics['POB Plan (Val)']),
doneMetrics: normalizeMetrics(metrics['POB Done (Val)']),
};
});
return {
territory: item.territory ?? '',
kamEmpCode: item.kam_emp_code ?? '',
kamName: item.kam_name ?? '',
brands,
};
});
return { brandGroups, rows, skuColumns: plannerSkuColumns(metricConfig, brandKeys, rows) };
}
export function buildKamTable(report, metricConfig, selectedBrands) {
const brandKeys = visibleBrandKeys(KAM_BRANDS, selectedBrands);
const attrToColumn = {};
const skuColumns = Object.fromEntries(brandKeys.map((key) => [key, []]));
asList(metricConfig).forEach((cfg) => {
const attr = cfg?.attribute ?? '';
const column = cfg?.column ?? '';
if (attr !== '' && column !== '') attrToColumn[attr] = column;
if (attr === '' || !brandKeys.includes(column)) return;
if (!skuColumns[column].includes(attr)) skuColumns[column].push(attr);
});
const brandColumns = brandKeys.map((key) => ({ key, label: KAM_BRANDS[key] }));
const rows = asList(report).map((raw) => {
const item = asObject(raw);
const metrics = {};
asList(item.metrics).forEach((metric) => {
const column = attrToColumn[metric?.attribute ?? ''] ?? '';
if (column === '') return;
if (!metrics[column]) metrics[column] = [];
metrics[column].push({ attribute: metric?.attribute ?? '', units: metric?.units ?? 0 });
});
return {
territory: item.territory ?? '',
kamEmpCode: item.kam_emp_code ?? '',
kamName: item.kam_name ?? '',
total: item['Total Brand POB Val'] ?? 0,
values: Object.fromEntries(brandKeys.map((key) => [key, item[key] ?? 0])),
metrics,
};
});
return { brandColumns, rows, skuColumns };
}
export function buildDrBrandTable(report, selectedBrands) {
const brandKeys = visibleBrandKeys(KAM_BRANDS, selectedBrands);
const brandColumns = brandKeys.map((key) => ({ key, label: KAM_BRANDS[key] }));
const rows = asList(report).map((raw) => {
const item = asObject(raw);
return {
drName: item['Dr Name'] ?? '',
drUid: item['Dr UID'] ?? '',
drSbu: item['Dr SBU'] ?? '',
total: item['Total Brand POB Val'] ?? 0,
values: Object.fromEntries(brandKeys.map((key) => [key, item[key] ?? 0])),
};
});
return { brandColumns, rows };
}
export function buildSkuTable(report, selectedBrands) {
const brandKeys = visibleBrandKeys(SKU_BRANDS, selectedBrands);
const skuGroups = brandKeys.map((brand) => ({
name: brand,
style: brandStyle(SKU_BRAND_COLORS, brand, 'iva-sku-'),
columns: SKU_GROUPS[brand].map((key) => ({ key, label: key.replace(/ Rate$/, '') })),
}));
const rows = asList(report).map((raw) => {
const item = asObject(raw);
const values = {};
skuGroups.forEach((group) => group.columns.forEach((col) => {
values[col.key] = item[col.key] ?? 0;
}));
return { drName: item['Dr Name'] ?? '', values };
});
return { skuGroups, rows };
}
export function buildTable(tab, payload, selectedBrands) {
if (tab === 'planner') return buildPlannerTable(payload.report, payload.metricConfig, selectedBrands);
if (tab === 'kamwise') return buildKamTable(payload.report, payload.metricConfig, selectedBrands);
if (tab === 'drbrandwise') return buildDrBrandTable(payload.report, selectedBrands);
return buildSkuTable(payload.report, selectedBrands);
}

View File

@ -0,0 +1,117 @@
import { useEffect, useMemo, useState } from 'react';
import { Form, Pagination } from 'react-bootstrap';
import { isNumeric } from './helpers';
const PAGE_LENGTHS = [10, 25, 50, 100];
function compare(a, b) {
if (isNumeric(a) && isNumeric(b)) return Number(a) - Number(b);
return String(a ?? '').localeCompare(String(b ?? ''), undefined, { numeric: true, sensitivity: 'base' });
}
// Mirrors the Laravel DataTables setup: pageLength 25, no initial order, search across shown text.
export function useDataTable(rows, cellsOf) {
const [search, setSearch] = useState('');
const [pageLength, setPageLength] = useState(25);
const [page, setPage] = useState(0);
const [sort, setSort] = useState(null);
useEffect(() => {
setPage(0);
}, [rows, search, pageLength, sort]);
const filtered = useMemo(() => {
const words = search.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return rows;
return rows.filter((row) => {
const text = cellsOf(row).map((cell) => cell.display).join(' ').toLowerCase();
return words.every((word) => text.includes(word));
});
}, [rows, search, cellsOf]);
const sorted = useMemo(() => {
if (!sort) return filtered;
const factor = sort.dir === 'asc' ? 1 : -1;
return filtered
.map((row, index) => ({ row, index }))
.sort((a, b) => factor * compare(cellsOf(a.row)[sort.index]?.raw, cellsOf(b.row)[sort.index]?.raw) || a.index - b.index)
.map((item) => item.row);
}, [filtered, sort, cellsOf]);
const pageCount = Math.max(1, Math.ceil(sorted.length / pageLength));
const current = Math.min(page, pageCount - 1);
const start = current * pageLength;
const visibleRows = sorted.slice(start, start + pageLength);
const toggleSort = (index) => {
setSort((prev) => (prev?.index === index ? { index, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { index, dir: 'asc' }));
};
const sortClass = (index) => {
if (sort?.index !== index) return 'iva-sortable';
return `iva-sortable iva-sorted-${sort.dir}`;
};
return {
search,
setSearch,
pageLength,
setPageLength,
page: current,
setPage,
pageCount,
start,
visibleRows,
filteredCount: sorted.length,
total: rows.length,
toggleSort,
sortClass,
};
}
export function DataTableTop({ table }) {
return (
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
<div className="d-flex align-items-center gap-1">
<span>Show</span>
<Form.Select size="sm" style={{ width: 80 }} value={table.pageLength} onChange={(event) => table.setPageLength(Number(event.target.value))}>
{PAGE_LENGTHS.map((length) => (
<option key={length} value={length}>{length}</option>
))}
</Form.Select>
<span>entries</span>
</div>
<div className="d-flex align-items-center gap-1">
<span>Search:</span>
<Form.Control size="sm" style={{ width: 200 }} value={table.search} onChange={(event) => table.setSearch(event.target.value)} />
</div>
</div>
);
}
function pageNumbers(page, pageCount) {
if (pageCount <= 7) return Array.from({ length: pageCount }, (_, index) => index);
if (page <= 3) return [0, 1, 2, 3, 4, 'end-gap', pageCount - 1];
if (page >= pageCount - 4) return [0, 'start-gap', pageCount - 5, pageCount - 4, pageCount - 3, pageCount - 2, pageCount - 1];
return [0, 'start-gap', page - 1, page, page + 1, 'end-gap', pageCount - 1];
}
export function DataTableBottom({ table }) {
const from = table.filteredCount ? table.start + 1 : 0;
const to = Math.min(table.start + table.visibleRows.length, table.filteredCount);
const filteredNote = table.filteredCount !== table.total ? ` (filtered from ${table.total} total entries)` : '';
return (
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mt-2">
<div>{`Showing ${from} to ${to} of ${table.filteredCount} entries${filteredNote}`}</div>
<Pagination size="sm" className="mb-0">
<Pagination.Prev disabled={table.page === 0} onClick={() => table.setPage(table.page - 1)}>Previous</Pagination.Prev>
{pageNumbers(table.page, table.pageCount).map((item) => (typeof item === 'number' ? (
<Pagination.Item key={item} active={item === table.page} onClick={() => table.setPage(item)}>{item + 1}</Pagination.Item>
) : (
<Pagination.Ellipsis key={item} disabled />
)))}
<Pagination.Next disabled={table.page >= table.pageCount - 1} onClick={() => table.setPage(table.page + 1)}>Next</Pagination.Next>
</Pagination>
</div>
);
}

View File

@ -0,0 +1,171 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Container, Form, Spinner } from 'react-bootstrap';
import { isSuccess, signedGet } from '@/api/client';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import { exportZenovaReport } from './exportXlsx';
import { REPORT_TIMEOUT, buildReport, clampLevel, defaultMonth, reportMonths } from './helpers';
const EMPTY_REPORT = { groups: [], body: [], totals: [], rawBody: [], rawTotals: [] };
function lineCells(groups, line) {
let index = 0;
return groups.flatMap((group) => group.columns.map((item) => {
const value = line[index] ?? '';
index += 1;
return { item, value, key: `${group.tone}-${item.section}-${item.key}` };
}));
}
export default function ZenovaDashboard() {
const months = useMemo(() => reportMonths(), []);
const [month, setMonth] = useState(() => defaultMonth(months));
const [level, setLevel] = useState(3);
const [reload, setReload] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [report, setReport] = useState(EMPTY_REPORT);
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState('');
useEffect(() => {
let cancelled = false;
setLoading(true);
setError('');
signedGet(
'reports',
'zenova_reports/glipiqsitazit_report',
{ level: String(clampLevel(level)), month },
{ timeout: REPORT_TIMEOUT },
).then((response) => {
if (cancelled) return;
if (!isSuccess(response)) {
setReport(EMPTY_REPORT);
setError(response?.message || 'Unable to load the Custom Dashboard.');
return;
}
setReport(buildReport(Array.isArray(response.data) ? response.data : []));
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [level, month, reload]);
const columnCount = report.groups.reduce((sum, group) => sum + group.columns.length, 0);
const handleExport = async () => {
setExporting(true);
setExportError('');
try {
await exportZenovaReport(report, `glipiq-sitazit-level-${level}.xlsx`);
} catch (err) {
setExportError(err.message || 'Unable to export the Custom Dashboard.');
} finally {
setExporting(false);
}
};
return (
<Container fluid className="zenova-page">
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2">
<PageBreadcrumb title="Custom Dashboard" />
<div className="d-flex flex-wrap align-items-center gap-2">
<Form.Label htmlFor="zenova-month" className="mb-0">Month</Form.Label>
<Form.Select id="zenova-month" value={month} style={{ width: 160 }} onChange={(event) => setMonth(event.target.value)}>
{months.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</Form.Select>
<Form.Label htmlFor="zenova-level" className="mb-0">Level</Form.Label>
<Form.Select id="zenova-level" value={level} style={{ width: 110 }} onChange={(event) => setLevel(clampLevel(event.target.value))}>
{[1, 2, 3, 4, 5].map((option) => (
<option key={option} value={option}>Level {option}</option>
))}
</Form.Select>
<Button variant="outline-secondary" onClick={() => setReload((value) => value + 1)} disabled={loading}>Refresh</Button>
<Button variant="primary" onClick={handleExport} disabled={loading || exporting || Boolean(error) || !columnCount}>
{exporting ? 'Exporting...' : 'Export'}
</Button>
</div>
</div>
<p className="text-muted mb-2">Please refresh before downloading the latest report.</p>
{error ? <div className="alert alert-danger">{error}</div> : null}
{exportError ? <div className="alert alert-danger">{exportError}</div> : null}
<div className="card">
<div className="card-body position-relative">
{loading ? (
<div className="zenova-loading">
<Spinner animation="border" />
</div>
) : null}
<div className="zenova-wrap">
<table className="zenova-table">
<thead>
<tr>
{report.groups.map((group) => {
const stickyCount = group.columns.filter((item) => item.sticky).length;
const scrollCount = group.columns.length - stickyCount;
return (
<GroupHeader key={group.label} group={group} stickyCount={stickyCount} scrollCount={scrollCount} />
);
})}
</tr>
<tr>
{report.groups.flatMap((group) => group.columns.map((item) => (
<th
key={`${group.label}-${item.key}`}
className={`tone-${group.tone}${item.type === 'text' ? ' text-cell' : ''}${item.sticky ? ` sticky-${item.sticky}` : ''}`}
>
{item.label}
</th>
)))}
</tr>
</thead>
<tbody>
{report.body.map((line, rowIndex) => (
<tr key={rowIndex}>
{lineCells(report.groups, line).map((cell) => (
<td
key={`${rowIndex}-${cell.key}`}
className={`${cell.item.type === 'text' ? 'text-cell' : ''}${cell.item.sticky ? ` sticky-${cell.item.sticky}` : ''}`}
>
{cell.value}
</td>
))}
</tr>
))}
{!loading && !error && !report.body.length ? (
<tr>
<td colSpan={Math.max(1, columnCount)}>No positions found for this level.</td>
</tr>
) : null}
{report.totals.length ? (
<tr className="totals">
{lineCells(report.groups, report.totals).map((cell) => (
<td
key={`total-${cell.key}`}
className={`${cell.item.type === 'text' ? 'text-cell' : ''}${cell.item.sticky ? ` sticky-${cell.item.sticky}` : ''}`}
>
{cell.value}
</td>
))}
</tr>
) : null}
</tbody>
</table>
</div>
</div>
</div>
</Container>
);
}
function GroupHeader({ group, stickyCount, scrollCount }) {
return (
<>
{stickyCount > 0 ? <th className={`tone-${group.tone} sticky-group`} colSpan={stickyCount}>{group.label}</th> : null}
{scrollCount > 0 ? <th className={`tone-${group.tone}`} colSpan={scrollCount}>{stickyCount > 0 ? '' : group.label}</th> : null}
</>
);
}

View File

@ -0,0 +1,97 @@
const TONE_COLORS = {
demo: 'FFFFF2CC',
day: 'FFDDEBF7',
yesterday: 'FFE2EFDA',
today: 'FFC6EFCE',
mtd: 'FFFCE4D6',
ytd: 'FFD9E2F3',
};
const THIN = { style: 'thin', color: { argb: 'FFB0B0B0' } };
const BORDER = { top: THIN, left: THIN, bottom: THIN, right: THIN };
function eachCell(sheet, startRow, startCol, endRow, endCol, visit) {
for (let row = startRow; row <= endRow; row += 1) {
for (let col = startCol; col <= endCol; col += 1) visit(sheet.getCell(row, col));
}
}
export async function exportZenovaReport(report, filename) {
const { default: ExcelJS } = await import('exceljs');
const { groups, rawBody, rawTotals } = report;
const columns = groups.flatMap((group) => group.columns);
if (!columns.length) return;
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Report');
const groupRow = [];
const labelRow = [];
groups.forEach((group) => {
group.columns.forEach((item, index) => {
groupRow.push(index === 0 ? group.label : '');
labelRow.push(item.label);
});
});
const lines = [groupRow, labelRow, ...rawBody];
if (rawTotals.length) lines.push(rawTotals);
lines.forEach((line) => sheet.addRow(line));
const rowCount = lines.length;
const colCount = columns.length;
eachCell(sheet, 1, 1, rowCount, colCount, (cell) => {
cell.font = { name: 'Calibri', size: 11 };
cell.border = BORDER;
cell.alignment = { vertical: 'middle' };
});
let col = 1;
groups.forEach((group) => {
const span = group.columns.length;
if (span > 1) sheet.mergeCells(1, col, 1, col + span - 1);
eachCell(sheet, 1, col, 2, col + span - 1, (cell) => {
cell.font = { name: 'Calibri', size: 11, bold: true, color: { argb: 'FF000000' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: TONE_COLORS[group.tone] || 'FFFFFFFF' } };
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
cell.border = BORDER;
});
group.columns.forEach((item, offset) => {
const columnIndex = col + offset;
sheet.getColumn(columnIndex).width = item.type === 'text' ? 24 : 16;
if (rowCount < 3) return;
for (let row = 3; row <= rowCount; row += 1) {
const cell = sheet.getCell(row, columnIndex);
if (item.type === 'text') {
cell.alignment = { horizontal: 'left', vertical: 'middle' };
} else if (item.type === 'percent') {
cell.numFmt = '0.0"%"';
cell.alignment = { horizontal: 'right', vertical: 'middle' };
} else {
cell.numFmt = '#,##0';
cell.alignment = { horizontal: 'right', vertical: 'middle' };
}
}
});
col += span;
});
sheet.getRow(1).height = 36;
sheet.getRow(2).height = 36;
sheet.views = [{ state: 'frozen', xSplit: 2, ySplit: 2 }];
if (rawTotals.length) {
eachCell(sheet, rowCount, 1, rowCount, colCount, (cell) => {
cell.font = { ...(cell.font || {}), name: 'Calibri', size: 11, bold: true };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF2F2F2' } };
});
}
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const anchor = document.createElement('a');
anchor.href = URL.createObjectURL(blob);
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(anchor.href);
}

View File

@ -0,0 +1,271 @@
export const REPORT_TIMEOUT = 120000;
const DAY_METRICS = ['GLIPIQ Total Rx', 'ABSI Camp Count', 'Patients Screened Count', 'Sitazit Total RX'];
const PERIOD_METRICS = [
'GLIPIQ Total Rx',
'GLIPIQ New Conversion',
'ABSI Camp Count',
'Patients Screened Count',
'GLIPIQ Total POB Units',
'Sitazit Total RX',
'Sitazit New Conversions',
'Sitazit Total POB Units',
];
const GEO_TYPES = ['Zone', 'Region', 'Area'];
export function reportMonths(now = new Date()) {
const startYear = now.getMonth() >= 3 ? now.getFullYear() : now.getFullYear() - 1;
const months = [];
const cursor = new Date(startYear, 3, 1);
const end = new Date(now.getFullYear(), now.getMonth(), 1);
while (cursor <= end) {
const value = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}`;
const label = cursor.toLocaleString('en-US', { month: 'long', year: 'numeric' });
months.push({ value, label });
cursor.setMonth(cursor.getMonth() + 1);
}
return months;
}
export function defaultMonth(months) {
return months.length ? months[months.length - 1].value : '';
}
export function clampLevel(level) {
const value = Number(level);
return value >= 1 && value <= 5 ? value : 3;
}
function strcasecmp(left, right) {
const a = String(left).toLowerCase();
const b = String(right).toLowerCase();
if (a < b) return -1;
if (a > b) return 1;
return 0;
}
function asRecord(value) {
return value && typeof value === 'object' ? value : {};
}
function rowName(row) {
const demo = asRecord(row['Demography & Details']);
const key = Object.keys(demo).find((name) => name.startsWith('Name of '));
if (key) return String(demo[key] ?? '');
return String(row.position_name ?? '');
}
function zoneName(row) {
const parents = asRecord(row.parent_positions);
const zoneKey = Object.keys(parents).find((key) => key.toLowerCase() === 'zone');
if (zoneKey && parents[zoneKey] != null && parents[zoneKey] !== '') return String(parents[zoneKey]);
const demo = asRecord(row['Demography & Details']);
const ownName = rowName(row);
const nameKey = Object.keys(demo).find((key) => key.toLowerCase() === 'name of zone');
if (!nameKey || demo[nameKey] == null || demo[nameKey] === '') return '';
if (String(demo[nameKey]).toLowerCase() === ownName.toLowerCase()) return '';
return String(demo[nameKey]);
}
function activeFsoTotal(row) {
const demo = row['Demography & Details'];
if (!demo || typeof demo !== 'object') return 0;
const glipiq = Number(demo['Glipiq Active FSOs']);
const sitazit = Number(demo['Sitazit Active FSOs']);
return (Number.isFinite(glipiq) ? glipiq : 0) + (Number.isFinite(sitazit) ? sitazit : 0);
}
function sortRows(rows) {
return [...rows].sort((left, right) => {
const zoneLeft = zoneName(left);
const zoneRight = zoneName(right);
if (zoneLeft === '' && zoneRight !== '') return 1;
if (zoneRight === '' && zoneLeft !== '') return -1;
const zone = strcasecmp(zoneLeft, zoneRight);
if (zone !== 0) return zone;
const active = activeFsoTotal(right) - activeFsoTotal(left);
if (active !== 0) return active;
return strcasecmp(rowName(left), rowName(right));
});
}
function column(label, section, key, type) {
return { label, section, key, type };
}
function geoColumns(nameKey) {
const currentType = nameKey.replace(/^Name of /, '');
return GEO_TYPES.map((type) => {
const sameLevel = currentType.toLowerCase() === type.toLowerCase();
const item = sameLevel
? column(type, 'Demography & Details', nameKey, 'text')
: column(type, 'parent_positions', type, 'text');
if (sameLevel) item.isTotal = true;
if (type === 'Zone' || type === 'Region') item.sticky = type.toLowerCase();
return item;
});
}
function metricColumns(section, metrics) {
return metrics.map((metric) => column(metric, section, metric, 'number'));
}
function sectionField(row, section, field) {
const block = row?.[section];
if (!block || typeof block !== 'object') return '';
const value = block[field];
return value == null ? '' : String(value);
}
function periodLabel(title, period) {
return period ? `${title} (${period})` : title;
}
function groupsFor(rows) {
let nameKey = 'Name of Region';
for (const row of rows) {
const demo = asRecord(row['Demography & Details']);
const key = Object.keys(demo).find((name) => name.startsWith('Name of '));
if (key) {
nameKey = key;
break;
}
}
const demoColumns = geoColumns(nameKey);
const currentType = nameKey.replace(/^Name of /, '').toLowerCase();
if (!['zone', 'region', 'area'].includes(currentType)) {
const nameColumn = column(nameKey, 'Demography & Details', nameKey, 'text');
nameColumn.isTotal = true;
demoColumns.push(nameColumn);
}
[
['No. of FSOs', 'number'],
['No. of DSMs', 'number'],
['Glipiq Active FSOs', 'number'],
['Glipiq Active FSO %', 'percent'],
['Sitazit Active FSOs', 'number'],
['Sitazit Active FSO %', 'percent'],
].forEach(([key, type]) => {
demoColumns.push(column(key, 'Demography & Details', key, type));
});
const groups = [{ label: 'Demography & Details (Data as per Yesterday)', tone: 'demo', columns: demoColumns }];
const first = rows[0] || null;
[
['day5', 'Day 5 Status'],
['day4', 'Day 4 Status'],
['day3', 'Day 3 Status'],
['day2', 'Day 2 Status'],
].forEach(([section, title]) => {
groups.push({
label: periodLabel(title, sectionField(first, section, 'date')),
tone: 'day',
columns: metricColumns(section, DAY_METRICS),
});
});
groups.push({
label: periodLabel('Day 1 / Yesterday', sectionField(first, 'day1', 'date')),
tone: 'yesterday',
columns: metricColumns('day1', DAY_METRICS),
});
groups.push({
label: periodLabel('Today', sectionField(first, 'today', 'date')),
tone: 'today',
columns: metricColumns('today', PERIOD_METRICS),
});
groups.push({
label: periodLabel('MTD', sectionField(first, 'mtd', 'period')),
tone: 'mtd',
columns: metricColumns('mtd', PERIOD_METRICS),
});
groups.push({
label: periodLabel('YTD', sectionField(first, 'ytd', 'period')),
tone: 'ytd',
columns: metricColumns('ytd', PERIOD_METRICS),
});
return groups;
}
function rawValue(row, item) {
if (item.section === 'parent_positions') {
const parents = asRecord(row.parent_positions);
const key = Object.keys(parents).find((name) => name.toLowerCase() !== 'country' && name.toLowerCase() === item.key.toLowerCase());
if (!key || parents[key] == null || parents[key] === '') return '';
return String(parents[key]);
}
const section = row[item.section];
const value = section && typeof section === 'object' ? section[item.key] : null;
if (item.type === 'text') return value == null || value === '' ? '-' : String(value);
const number = Number(value);
return Number.isFinite(number) && String(value).trim() !== '' ? number : 0;
}
function sumKey(rows, section, key) {
return rows.reduce((total, row) => {
const block = row[section];
const value = block && typeof block === 'object' ? Number(block[key]) : NaN;
return total + (Number.isFinite(value) ? value : 0);
}, 0);
}
function formatValue(value, type) {
if (type === 'text') return String(value);
const number = Number(value) || 0;
if (type === 'percent') {
return `${number.toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%`;
}
if (Math.floor(number) === number) return number.toLocaleString('en-US', { maximumFractionDigits: 0 });
return number.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export function buildReport(rows) {
const sorted = sortRows(Array.isArray(rows) ? rows : []);
const groups = groupsFor(sorted);
const columns = groups.flatMap((group) => group.columns);
const body = [];
const rawBody = [];
const summed = columns.map(() => 0);
sorted.forEach((row) => {
const line = [];
const rawLine = [];
columns.forEach((item, index) => {
const raw = rawValue(row, item);
if (item.type !== 'text') summed[index] += raw;
rawLine.push(raw);
line.push(formatValue(raw, item.type));
});
body.push(line);
rawBody.push(rawLine);
});
const totals = [];
const rawTotals = [];
if (sorted.length) {
const fsos = sumKey(sorted, 'Demography & Details', 'No. of FSOs');
const glipiqActive = sumKey(sorted, 'Demography & Details', 'Glipiq Active FSOs');
const sitazitActive = sumKey(sorted, 'Demography & Details', 'Sitazit Active FSOs');
columns.forEach((item, index) => {
if (item.type === 'text') {
const label = item.isTotal ? 'Total' : '';
totals.push(label);
rawTotals.push(label);
return;
}
if (item.key === 'Glipiq Active FSO %' || item.key === 'Sitazit Active FSO %') {
const active = item.key === 'Glipiq Active FSO %' ? glipiqActive : sitazitActive;
const percent = fsos ? (active / fsos) * 100 : 0;
totals.push(formatValue(percent, 'percent'));
rawTotals.push(percent);
return;
}
totals.push(formatValue(summed[index], item.type));
rawTotals.push(summed[index]);
});
}
return { groups, body, totals, rawBody, rawTotals };
}