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/rcpa";
|
||||
@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 { 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 { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
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 formId = searchParams.get('id') || '';
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -217,6 +217,17 @@ function DataDesignerEditor({ value, onChange, userAttributes = {} }) {
|
|||
option('created_date', 'question', 'created_date', 'Submitted Date'),
|
||||
option('updated_date', 'question', 'updated_date', 'Last Updated Date'),
|
||||
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) => {
|
||||
|
|
@ -322,12 +333,22 @@ function DataDesignerEditor({ value, onChange, userAttributes = {} }) {
|
|||
}
|
||||
|
||||
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 (
|
||||
<Controller
|
||||
name={field.key}
|
||||
control={control}
|
||||
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 { permissions, roles, domain, user } = useAuth();
|
||||
const menuItems = useMemo(
|
||||
() => getMenuItems({ permissions, roles, domain, email: user?.email }),
|
||||
[permissions, roles, domain, user?.email],
|
||||
() => getMenuItems({ permissions, roles, domain, email: user?.email, designation: user?.designation }),
|
||||
[permissions, roles, domain, user?.email, user?.designation],
|
||||
);
|
||||
const scrollToActiveLink = () => {
|
||||
const activeItem = document.querySelector('.side-nav-link.active');
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ const child = (key, label, url, permission, extra = {}) => ({
|
|||
...extra,
|
||||
});
|
||||
|
||||
export function getMenuItems({ permissions = [], roles = [], domain = '', email = '' } = {}) {
|
||||
export function getMenuItems({ permissions = [], roles = [], domain = '', email = '', designation = '' } = {}) {
|
||||
const items = [
|
||||
{
|
||||
key: 'domain-title',
|
||||
|
|
@ -103,6 +103,14 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
|
|||
icon: LuFileText,
|
||||
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') {
|
||||
|
|
@ -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))) {
|
||||
items.push({
|
||||
key: 'glipiq-sitazit',
|
||||
|
|
|
|||
|
|
@ -94,6 +94,11 @@ const RcpaTmWise = lazy(() => import('@/views/rcpareport/TmWise'));
|
|||
const RcpaMonthWise = lazy(() => import('@/views/rcpareport/MonthWise'));
|
||||
const RcpaFormWise = lazy(() => import('@/views/rcpareport/FormWise'));
|
||||
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) {
|
||||
const routes = [
|
||||
|
|
@ -225,6 +230,16 @@ export const routes = [
|
|||
{ path: '/rcpareport/monthwise', element: <RcpaMonthWise /> },
|
||||
{ path: '/rcpareport/formwise', element: <RcpaFormWise /> },
|
||||
{ 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: '*', 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