added 4 domain custom reports and data designer changes
This commit is contained in:
parent
22c4391380
commit
db593f9a6c
|
|
@ -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";
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 /> },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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' : '';
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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)}`;
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue