diff --git a/src/assets/scss/app.scss b/src/assets/scss/app.scss index 8706597..706e441 100644 --- a/src/assets/scss/app.scss +++ b/src/assets/scss/app.scss @@ -1,99 +1,103 @@ -/** - * Template Name: UBold - Admin & Dashboard Template - * By (Author): Coderthemes - * Module/App (File Name): App CSS File -*/ - - -// Core files -@import "bootstrap/scss/functions"; -@import "bootstrap/scss/variables"; - -@import "variables"; -@import "variables-dark"; - -@import "bootstrap/scss/bootstrap"; - -@import "config/root"; -@import "config/theme-default"; // Default Theme -@import "config/theme-saas"; -@import "config/theme-modern"; -@import "config/theme-material"; -@import "config/theme-minimal"; -@import "config/theme-flat"; - -// Structure -@import "structure/topbar"; -@import "structure/sidenav"; -@import "structure/horizontal"; -@import "structure/layout"; -@import "structure/footer"; - -// Components -@import "components/background"; -@import "components/accordions"; -@import "components/alert"; -@import "components/avatar"; -@import "components/breadcrumb"; -@import "components/buttons"; -@import "components/badge"; -@import "components/card"; -@import "components/dropdown"; -@import "components/forms"; -@import "components/modal"; -@import "components/nav"; -@import "components/pagination"; -@import "components/popover"; -@import "components/print"; -@import "components/progress"; -@import "components/reboot"; -@import "components/tables"; -@import "components/tooltip"; -@import "components/preloader"; -@import "components/widgets"; -@import "components/list-group"; - - -// Pages -@import "pages/authentication"; -@import "pages/chat"; -@import "pages/email"; -@import "pages/error"; -@import "pages/kanban-board"; -@import "pages/landing"; -@import "pages/outlook"; -@import "pages/sitemap"; -@import "pages/timeline"; - - -// Plugins -@import "plugins/apexcharts"; -@import "plugins/calendar"; -@import "plugins/choice"; -@import "plugins/colorpicker"; -@import "plugins/datatables"; -@import "plugins/daterangepicker"; -@import "plugins/dropzone"; -@import "plugins/filepond"; -@import "plugins/flatpickr"; -@import "plugins/jvectormap"; -@import "plugins/noui-rangeslider"; -@import "plugins/pass-bar"; -@import "plugins/prismjs"; -@import "plugins/quill-editor"; -@import "plugins/select2"; -@import "plugins/simplebar"; -@import "plugins/sortablejs"; -@import "plugins/summernote"; -@import "plugins/sweetalert2"; -@import "plugins/touchpin"; -@import "plugins/tour"; -@import "plugins/treeview"; -@import "plugins/typehead"; -@import "plugins/wizard"; -@import "plugins/react-select"; -@import "plugins/react-slider"; -@import "plugins/multiselect"; -@import "pages/dashboard"; -@import "pages/rcpa"; -@import "pages/gennovadc"; \ No newline at end of file +/** + * Template Name: UBold - Admin & Dashboard Template + * By (Author): Coderthemes + * Module/App (File Name): App CSS File +*/ + + +// Core files +@import "bootstrap/scss/functions"; +@import "bootstrap/scss/variables"; + +@import "variables"; +@import "variables-dark"; + +@import "bootstrap/scss/bootstrap"; + +@import "config/root"; +@import "config/theme-default"; // Default Theme +@import "config/theme-saas"; +@import "config/theme-modern"; +@import "config/theme-material"; +@import "config/theme-minimal"; +@import "config/theme-flat"; + +// Structure +@import "structure/topbar"; +@import "structure/sidenav"; +@import "structure/horizontal"; +@import "structure/layout"; +@import "structure/footer"; + +// Components +@import "components/background"; +@import "components/accordions"; +@import "components/alert"; +@import "components/avatar"; +@import "components/breadcrumb"; +@import "components/buttons"; +@import "components/badge"; +@import "components/card"; +@import "components/dropdown"; +@import "components/forms"; +@import "components/modal"; +@import "components/nav"; +@import "components/pagination"; +@import "components/popover"; +@import "components/print"; +@import "components/progress"; +@import "components/reboot"; +@import "components/tables"; +@import "components/tooltip"; +@import "components/preloader"; +@import "components/widgets"; +@import "components/list-group"; + + +// Pages +@import "pages/authentication"; +@import "pages/chat"; +@import "pages/email"; +@import "pages/error"; +@import "pages/kanban-board"; +@import "pages/landing"; +@import "pages/outlook"; +@import "pages/sitemap"; +@import "pages/timeline"; + + +// Plugins +@import "plugins/apexcharts"; +@import "plugins/calendar"; +@import "plugins/choice"; +@import "plugins/colorpicker"; +@import "plugins/datatables"; +@import "plugins/daterangepicker"; +@import "plugins/dropzone"; +@import "plugins/filepond"; +@import "plugins/flatpickr"; +@import "plugins/jvectormap"; +@import "plugins/noui-rangeslider"; +@import "plugins/pass-bar"; +@import "plugins/prismjs"; +@import "plugins/quill-editor"; +@import "plugins/select2"; +@import "plugins/simplebar"; +@import "plugins/sortablejs"; +@import "plugins/summernote"; +@import "plugins/sweetalert2"; +@import "plugins/touchpin"; +@import "plugins/tour"; +@import "plugins/treeview"; +@import "plugins/typehead"; +@import "plugins/wizard"; +@import "plugins/react-select"; +@import "plugins/react-slider"; +@import "plugins/multiselect"; +@import "pages/dashboard"; +@import "pages/rcpa"; +@import "pages/gennovadc"; +@import "pages/zenova"; +@import "pages/coaching"; +@import "pages/adherence"; +@import "pages/iva"; \ No newline at end of file diff --git a/src/assets/scss/pages/_adherence.scss b/src/assets/scss/pages/_adherence.scss new file mode 100644 index 0000000..6e522a4 --- /dev/null +++ b/src/assets/scss/pages/_adherence.scss @@ -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; + } +} diff --git a/src/assets/scss/pages/_coaching.scss b/src/assets/scss/pages/_coaching.scss new file mode 100644 index 0000000..9a75753 --- /dev/null +++ b/src/assets/scss/pages/_coaching.scss @@ -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; + } +} diff --git a/src/assets/scss/pages/_iva.scss b/src/assets/scss/pages/_iva.scss new file mode 100644 index 0000000..ace2fee --- /dev/null +++ b/src/assets/scss/pages/_iva.scss @@ -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; } diff --git a/src/assets/scss/pages/_zenova.scss b/src/assets/scss/pages/_zenova.scss new file mode 100644 index 0000000..47b8eea --- /dev/null +++ b/src/assets/scss/pages/_zenova.scss @@ -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; } +} diff --git a/src/components/form/DataDesignerField.jsx b/src/components/form/DataDesignerField.jsx index 621b910..d42cfa3 100644 --- a/src/components/form/DataDesignerField.jsx +++ b/src/components/form/DataDesignerField.jsx @@ -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 ( ( - + )} /> ); diff --git a/src/layouts/components/sidenav/components/AppMenu.jsx b/src/layouts/components/sidenav/components/AppMenu.jsx index 0cd79ad..4866a89 100644 --- a/src/layouts/components/sidenav/components/AppMenu.jsx +++ b/src/layouts/components/sidenav/components/AppMenu.jsx @@ -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'); diff --git a/src/layouts/menu.js b/src/layouts/menu.js index 9101c85..3f3836c 100644 --- a/src/layouts/menu.js +++ b/src/layouts/menu.js @@ -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', diff --git a/src/routes/index.jsx b/src/routes/index.jsx index d1172b1..21272a8 100644 --- a/src/routes/index.jsx +++ b/src/routes/index.jsx @@ -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: }, { path: '/rcpareport/formwise', element: }, { path: '/gennovadcreport/dashboard', element: }, + { path: '/zenovareport/dashboard', element: }, + { path: '/coachingtooldashboard/sbmandmeactionplanreport', element: }, + { path: '/coachingtooldashboard/rmactionplanreport', element: }, + { path: '/coachingtooldashboard/sbmandmegoalandproficiencytrackingreport', element: }, + { path: '/coachingtooldashboard/rmgoalandproficiencytracking', element: }, + { path: '/adherencepobdrivereport/report', element: }, + { path: '/ivareport/planner', element: }, + { path: '/ivareport/kamwise', element: }, + { path: '/ivareport/drbrandwise', element: }, + { path: '/ivareport/skuwise', element: }, { path: '/coming-soon', element: }, { path: '*', element: }, ], diff --git a/src/views/adherencepobdrivereport/Report.jsx b/src/views/adherencepobdrivereport/Report.jsx new file mode 100644 index 0000000..e21080b --- /dev/null +++ b/src/views/adherencepobdrivereport/Report.jsx @@ -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) => ( + + {index > 0 ?
: null} + {line} +
+ )); +} + +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 ( + + +
+
+

{TITLE}

+
You do not have access to this report.
+
+
+
+ ); + } + + return ( + + +
+
+

{TITLE}

+

Adherence Pack POB of at least ₹15,000. Changing the month reloads the report.

+ {error ?
{error}
: null} +
+
+ Month + setSelected(event.target.value)}> + {months.map((option) => ( + + ))} + +
+
+ +
+ {!loading && sections.length ? ( +
+ +
+ ) : null} +
+ {loading ?

Loading the report wait.....

: null} +
+
+ + {!loading && sections.length ? ( +
+
+ +
+ {sections.filter((section) => section.id === activeTab).map((section) => ( +
+ + + + {section.dayTypes.map((type, index) => ( + + ))} + + + {section.headers.map((header, index) => ( + + ))} + + + + {section.rows.length ? section.rows.map((row, rowIndex) => ( + + {row.cells.map((cell, index) => ( + + ))} + + )) : ( + + + + )} + +
{type}
+ +
{String(cell)}
No rows for this month.
+
+ ))} +
+
+
+ ) : null} +
+ ); +} diff --git a/src/views/adherencepobdrivereport/exportXlsx.js b/src/views/adherencepobdrivereport/exportXlsx.js new file mode 100644 index 0000000..b3ad466 --- /dev/null +++ b/src/views/adherencepobdrivereport/exportXlsx.js @@ -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); +} diff --git a/src/views/adherencepobdrivereport/helpers.js b/src/views/adherencepobdrivereport/helpers.js new file mode 100644 index 0000000..0dec136 --- /dev/null +++ b/src/views/adherencepobdrivereport/helpers.js @@ -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' : ''; +} diff --git a/src/views/coachingtooldashboard/ActionPlanReport.jsx b/src/views/coachingtooldashboard/ActionPlanReport.jsx new file mode 100644 index 0000000..3bc8af0 --- /dev/null +++ b/src/views/coachingtooldashboard/ActionPlanReport.jsx @@ -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 ( + +
+ + +
+ {error ?
{error}
: null} + {exportError ?
{exportError}
: null} +
+
+ {loading ? ( +
+ +
+ ) : null} + + + + + {columns.map((column) => ( + + ))} + + + + {rows.map((row) => ( + + + {columns.map((column) => ( + + ))} + + ))} + +
{report.columnTitle}{column === 'total' ? 'Total' : columnLabel(column)}
{row.label}{row[column] ?? 0}
+
+
+
+ ); +} diff --git a/src/views/coachingtooldashboard/GoalProficiencyReport.jsx b/src/views/coachingtooldashboard/GoalProficiencyReport.jsx new file mode 100644 index 0000000..4e1ddd8 --- /dev/null +++ b/src/views/coachingtooldashboard/GoalProficiencyReport.jsx @@ -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 ( + +
+ + +
+ {error ?
{error}
: null} + {exportError ?
{exportError}
: null} +
+
+ {loading ? ( +
+ +
+ ) : null} + + + + + {grid.months.map((month) => ( + + ))} + + + {grid.months.flatMap((month) => RATINGS.map((rating) => ( + + )))} + + + + {grid.goals.map((goalName, index) => ( + + + {grid.months.flatMap((month) => RATINGS.map((rating) => ( + + )))} + + ))} + +
{report.columnTitle}{monthHeading(month)}
{rating}
Goal {index + 1}: {goalName}* + {goalCount(grid.byMonth, month, goalName, rating)} +
+
+
+
+ ); +} diff --git a/src/views/coachingtooldashboard/exportXlsx.js b/src/views/coachingtooldashboard/exportXlsx.js new file mode 100644 index 0000000..d566f06 --- /dev/null +++ b/src/views/coachingtooldashboard/exportXlsx.js @@ -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); +} diff --git a/src/views/coachingtooldashboard/helpers.js b/src/views/coachingtooldashboard/helpers.js new file mode 100644 index 0000000..7186fb3 --- /dev/null +++ b/src/views/coachingtooldashboard/helpers.js @@ -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)}`; +} diff --git a/src/views/ivareport/DrBrandWise.jsx b/src/views/ivareport/DrBrandWise.jsx new file mode 100644 index 0000000..d3d029c --- /dev/null +++ b/src/views/ivareport/DrBrandWise.jsx @@ -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 ? : null} +
+ + + + {headers.map((header, index) => ( + + ))} + + + + {shown.length ? shown.map((row, rowIndex) => ( + + {cellsOf(row).map((cell, index) => ( + + ))} + + )) : ( + + + + )} + +
data.toggleSort(index) : undefined} + > + {header} +
{cell.display}
+ {paged ? 'No matching records found' : 'No data'} +
+
+ {paged ? : null} + + ); +} diff --git a/src/views/ivareport/KamWise.jsx b/src/views/ivareport/KamWise.jsx new file mode 100644 index 0000000..49a3f40 --- /dev/null +++ b/src/views/ivareport/KamWise.jsx @@ -0,0 +1,35 @@ +import { formatNumber } from './helpers'; + +export default function KamWise({ table }) { + const columns = table.brandColumns; + return ( + + + + + + + {columns.map((col) => ( + + ))} + + + + {table.rows.length ? table.rows.map((row, rowIndex) => ( + + + + + {columns.map((col) => ( + + ))} + + )) : ( + + + + )} + +
TerritoryKAM NameTotal Brand POB Val{col.key}
{row.territory}{row.kamName}{formatNumber(row.total ?? 0)}{formatNumber(row.values[col.key] ?? 0)}
No data
+ ); +} diff --git a/src/views/ivareport/MultiSelectFilter.jsx b/src/views/ivareport/MultiSelectFilter.jsx new file mode 100644 index 0000000..a38b9d2 --- /dev/null +++ b/src/views/ivareport/MultiSelectFilter.jsx @@ -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 ( + + + {count ? `${label} (${count})` : label} + + +
+ {searchable ? ( +
+ setSearch(event.target.value)} + /> +
+ ) : null} + {options.length ? ( + visible.map((option) => ( + toggleOption(option.value)} + /> + )) + ) : ( +
{emptyText}
+ )} +
+
+
+ + +
+
+
+ ); +} diff --git a/src/views/ivareport/Planner.jsx b/src/views/ivareport/Planner.jsx new file mode 100644 index 0000000..2443e7c --- /dev/null +++ b/src/views/ivareport/Planner.jsx @@ -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 ( + + + + + + {groups.map((group) => ( + + ))} + + + {groups.flatMap((group) => group.columns.map((col, index) => ( + + )))} + + + + {table.rows.length ? table.rows.map((row, rowIndex) => ( + + + + {groups.map((group) => { + const brand = row.brands[group.name] ?? {}; + return [ + , + , + , + ]; + })} + + )) : ( + + + + )} + +
TerritoryKAM Name{group.name}
{col.label}
{row.territory}{row.kamName}{formatNumber(brand.plan ?? 0)}{formatNumber(brand.done ?? 0)}{formatNumber(brand.pct ?? 0, true)}
No data
+ ); +} diff --git a/src/views/ivareport/ReportPage.jsx b/src/views/ivareport/ReportPage.jsx new file mode 100644 index 0000000..c0ca410 --- /dev/null +++ b/src/views/ivareport/ReportPage.jsx @@ -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 ( + + +
You do not have access to this report.
+
+ ); + } + + const View = VIEWS[tab]; + const scrolls = tab === 'planner' || tab === 'kamwise'; + + return ( + + +
+
+

{config.label}

+
+
+
+ applyFilters({ hierarchyIds: ids })} + searchable + emptyText="No hierarchy available" + /> + applyFilters({ selectedBrands: values })} + /> + applyFilters({ month: event.target.value })}> + {months.map((month) => ( + + ))} + +
+
+
+ + {error ?
{error}
: null} + + + +
+
+
+
+
{config.label}
+ +
+
+ {loading ? ( +
+ +
+ ) : null} + {scrolls ? ( +
+ +
+ ) : ( + + )} +
+
+
+
+
+ ); +} diff --git a/src/views/ivareport/SkuWise.jsx b/src/views/ivareport/SkuWise.jsx new file mode 100644 index 0000000..aba0ca0 --- /dev/null +++ b/src/views/ivareport/SkuWise.jsx @@ -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 ? : null} +
+ + + + + {groups.map((group) => ( + + ))} + + + {skuColumns.map((col, index) => { + const sort = sortable(index + 1); + return ( + + ); + })} + + + + {shown.length ? shown.map((row, rowIndex) => ( + + {cellsOf(row).map((cell, index) => ( + + ))} + + )) : ( + + + + )} + +
Dr Name{group.name}
+ {col.key} +
+ {cell.display} +
+ {paged ? 'No matching records found' : 'No data'} +
+
+ {paged ? : null} + + ); +} diff --git a/src/views/ivareport/exportXlsx.js b/src/views/ivareport/exportXlsx.js new file mode 100644 index 0000000..549b46c --- /dev/null +++ b/src/views/ivareport/exportXlsx.js @@ -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); +} diff --git a/src/views/ivareport/helpers.js b/src/views/ivareport/helpers.js new file mode 100644 index 0000000..e2d0084 --- /dev/null +++ b/src/views/ivareport/helpers.js @@ -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); +} diff --git a/src/views/ivareport/useDataTable.jsx b/src/views/ivareport/useDataTable.jsx new file mode 100644 index 0000000..626a2dd --- /dev/null +++ b/src/views/ivareport/useDataTable.jsx @@ -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 ( +
+
+ Show + table.setPageLength(Number(event.target.value))}> + {PAGE_LENGTHS.map((length) => ( + + ))} + + entries +
+
+ Search: + table.setSearch(event.target.value)} /> +
+
+ ); +} + +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 ( +
+
{`Showing ${from} to ${to} of ${table.filteredCount} entries${filteredNote}`}
+ + table.setPage(table.page - 1)}>Previous + {pageNumbers(table.page, table.pageCount).map((item) => (typeof item === 'number' ? ( + table.setPage(item)}>{item + 1} + ) : ( + + )))} + = table.pageCount - 1} onClick={() => table.setPage(table.page + 1)}>Next + +
+ ); +} diff --git a/src/views/zenovareport/Dashboard.jsx b/src/views/zenovareport/Dashboard.jsx new file mode 100644 index 0000000..04341fa --- /dev/null +++ b/src/views/zenovareport/Dashboard.jsx @@ -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 ( + +
+ +
+ Month + setMonth(event.target.value)}> + {months.map((option) => ( + + ))} + + Level + setLevel(clampLevel(event.target.value))}> + {[1, 2, 3, 4, 5].map((option) => ( + + ))} + + + +
+
+

Please refresh before downloading the latest report.

+ {error ?
{error}
: null} + {exportError ?
{exportError}
: null} +
+
+ {loading ? ( +
+ +
+ ) : null} +
+ + + + {report.groups.map((group) => { + const stickyCount = group.columns.filter((item) => item.sticky).length; + const scrollCount = group.columns.length - stickyCount; + return ( + + ); + })} + + + {report.groups.flatMap((group) => group.columns.map((item) => ( + + )))} + + + + {report.body.map((line, rowIndex) => ( + + {lineCells(report.groups, line).map((cell) => ( + + ))} + + ))} + {!loading && !error && !report.body.length ? ( + + + + ) : null} + {report.totals.length ? ( + + {lineCells(report.groups, report.totals).map((cell) => ( + + ))} + + ) : null} + +
+ {item.label} +
+ {cell.value} +
No positions found for this level.
+ {cell.value} +
+
+
+
+
+ ); +} + +function GroupHeader({ group, stickyCount, scrollCount }) { + return ( + <> + {stickyCount > 0 ? {group.label} : null} + {scrollCount > 0 ? {stickyCount > 0 ? '' : group.label} : null} + + ); +} diff --git a/src/views/zenovareport/exportXlsx.js b/src/views/zenovareport/exportXlsx.js new file mode 100644 index 0000000..6aa832c --- /dev/null +++ b/src/views/zenovareport/exportXlsx.js @@ -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); +} diff --git a/src/views/zenovareport/helpers.js b/src/views/zenovareport/helpers.js new file mode 100644 index 0000000..1774a79 --- /dev/null +++ b/src/views/zenovareport/helpers.js @@ -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 }; +}