From 7355130e029097fc8b448eb47b4800422f5be291 Mon Sep 17 00:00:00 2001 From: Amey Bhagwat Date: Tue, 13 Oct 2026 11:49:50 +0530 Subject: [PATCH] ## Added missing atrributes like user_attribute toggle and actual select list fields in contacts, leaderboard, metric campaigns, users etc. ## Added custom modules in react: 1) RCPA report - Added AG grid "ag-grid-community": "^32.3.9", "ag-grid-react": "^32.3.9", 2) MYDTx Dashboard: (/gennovadcreport/dashboard) - Change csv export to excel - installed new package: "exceljs": "^4.4.0" - Applied Hierarchy filter to all eight report (specially for 1-5 which is not in laravel app) --- src/api/client.js | 17 +- src/api/config.js | 2 +- src/api/dropdowns.js | 10 + src/api/laravel.js | 59 +++ src/assets/scss/app.scss | 4 +- src/assets/scss/pages/_gennovadc.scss | 67 ++++ src/assets/scss/pages/_rcpa.scss | 29 ++ src/components/EntityForm.jsx | 15 +- src/components/EntityList.jsx | 5 +- src/layouts/menu.js | 2 +- src/modules/contactexports.js | 13 +- src/modules/contacts.js | 2 +- src/modules/formentitys.js | 7 +- src/modules/leaderboards.js | 26 +- src/modules/metriccampaigns.js | 24 +- src/modules/metricviews.js | 7 +- src/modules/milestones.js | 26 +- src/modules/rcpacompetitors.js | 21 +- src/modules/rcpaforms.js | 10 +- src/modules/userattributes.js | 7 +- src/modules/users.js | 138 +++++-- src/routes/index.jsx | 23 +- src/views/gennovadcreport/Dashboard.jsx | 188 +++++++++ src/views/gennovadcreport/HierarchyFilter.jsx | 73 ++++ src/views/gennovadcreport/ReportSection.jsx | 104 +++++ src/views/gennovadcreport/exportXlsx.js | 48 +++ src/views/gennovadcreport/helpers.js | 363 ++++++++++++++++++ src/views/neblogistic/DepotPage.jsx | 129 +++++++ src/views/neblogistic/ImportPage.jsx | 32 ++ src/views/neblogistic/MappingPage.jsx | 146 +++++++ src/views/neblogistic/ReportDetail.jsx | 308 +++++++++++++++ src/views/neblogistic/ReportList.jsx | 209 ++++++++++ src/views/neblogistic/helpers.js | 79 ++++ src/views/organisation/HierarchyTree.jsx | 30 +- src/views/rcpareport/FormWise.jsx | 185 +++++++++ src/views/rcpareport/MonthWise.jsx | 128 ++++++ src/views/rcpareport/ReportGrid.jsx | 73 ++++ src/views/rcpareport/TmWise.jsx | 107 ++++++ src/views/rcpareport/helpers.js | 89 +++++ 39 files changed, 2721 insertions(+), 84 deletions(-) create mode 100644 src/api/laravel.js create mode 100644 src/assets/scss/pages/_gennovadc.scss create mode 100644 src/assets/scss/pages/_rcpa.scss create mode 100644 src/views/gennovadcreport/Dashboard.jsx create mode 100644 src/views/gennovadcreport/HierarchyFilter.jsx create mode 100644 src/views/gennovadcreport/ReportSection.jsx create mode 100644 src/views/gennovadcreport/exportXlsx.js create mode 100644 src/views/gennovadcreport/helpers.js create mode 100644 src/views/neblogistic/DepotPage.jsx create mode 100644 src/views/neblogistic/ImportPage.jsx create mode 100644 src/views/neblogistic/MappingPage.jsx create mode 100644 src/views/neblogistic/ReportDetail.jsx create mode 100644 src/views/neblogistic/ReportList.jsx create mode 100644 src/views/neblogistic/helpers.js create mode 100644 src/views/rcpareport/FormWise.jsx create mode 100644 src/views/rcpareport/MonthWise.jsx create mode 100644 src/views/rcpareport/ReportGrid.jsx create mode 100644 src/views/rcpareport/TmWise.jsx create mode 100644 src/views/rcpareport/helpers.js diff --git a/src/api/client.js b/src/api/client.js index 144683f..2e9c647 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -84,16 +84,25 @@ async function request(method, service, path, data = {}, { signed = true, timeou const payload = error.response?.data; const apiStatus = payload?.statusCode; if (signed && (status === 401 || apiStatus === 401)) { + let token; + try { + token = await ensureRefresh(); + } catch { + clearAndLogout(); + return payload || { statusCode: 401, status: 'error', message: 'Unauthorised' }; + } try { - const token = await ensureRefresh(); const retry = await axios({ ...config, headers: { ...headers, Authorization: token }, }); return retry.data; - } catch { - clearAndLogout(); - return payload || { statusCode: 401, status: 'error', message: 'Unauthorised' }; + } catch (retryError) { + return ( + retryError.response?.data || + payload || + { statusCode: 401, status: 'error', message: 'Unauthorised' } + ); } } if (payload) return payload; diff --git a/src/api/config.js b/src/api/config.js index e050257..e1b1755 100644 --- a/src/api/config.js +++ b/src/api/config.js @@ -25,7 +25,7 @@ export const LOCAL_SERVICES = { export const isLocalApi = () => import.meta.env.VITE_API_MODE === 'local'; export function resolveUrl(service, path) { - if (isLocalApi()) { + if (isLocalApi() || import.meta.env.DEV) { return `/api/${service}/${path}`; } // Do not call Lambda Function URLs from the browser. Their CORS config diff --git a/src/api/dropdowns.js b/src/api/dropdowns.js index 79bb172..ad7f31f 100644 --- a/src/api/dropdowns.js +++ b/src/api/dropdowns.js @@ -1,6 +1,16 @@ import { signedGet, isSuccess } from '@/api/client'; import { loadRoleMap } from '@/helpers/roles'; +export async function fetchUserAttributes() { + try { + const response = await signedGet('ums', 'userattribute/get_all'); + if (!isSuccess(response)) return []; + return Array.isArray(response.data?.items) ? response.data.items : []; + } catch { + return []; + } +} + export async function dropdownMap(service, module, labelFn, keyFn) { if (service === 'ums' && module === 'roles' && !keyFn) { return loadRoleMap(); diff --git a/src/api/laravel.js b/src/api/laravel.js new file mode 100644 index 0000000..188cfb7 --- /dev/null +++ b/src/api/laravel.js @@ -0,0 +1,59 @@ +import axios from 'axios'; +import { getAccessToken, getSession } from '@/auth/storage'; + +export function laravelUrl(path) { + const clean = String(path || '').replace(/^\//, ''); + if (import.meta.env.DEV) return `/laravel-api/${clean}`; + const base = (import.meta.env.VITE_LARAVEL_URL || 'https://web.buildrapport.co').replace(/\/$/, ''); + return `${base}/api/${clean}`; +} + +export async function laravelRequest(method, path, data = {}, { timeout = 60000, responseType } = {}) { + const token = getAccessToken(); + const session = getSession(); + const headers = {}; + if (token) headers.Authorization = token; + if (session?.user?.id) headers['X-Rapport-User-Id'] = session.user.id; + if (session?.domain || session?.user?.domain) headers['X-Rapport-Domain'] = session.domain || session.user.domain; + const config = { + method, + url: laravelUrl(path), + headers, + timeout, + withCredentials: false, + }; + if (responseType) config.responseType = responseType; + if (method === 'get') config.params = data; + else if (data instanceof FormData) { + config.data = data; + config.headers['Content-Type'] = 'multipart/form-data'; + } else config.data = data; + + try { + const response = await axios(config); + return responseType ? response : response.data; + } catch (error) { + const payload = error.response?.data; + if (payload && typeof payload === 'object') return payload; + return { + statusCode: error.response?.status || 0, + status: 'error', + message: + error.message === 'Network Error' + ? 'Network Error: Laravel API was blocked or unreachable. Start Rapport-Laravel and retry.' + : error.message || 'Network Error', + }; + } +} + +export function laravelGet(path, data = {}, options = {}) { + return laravelRequest('get', path, data, options); +} + +export function laravelPost(path, data = {}, options = {}) { + return laravelRequest('post', path, data, options); +} + +export function isLaravelSuccess(payload) { + return payload && (payload.statusCode === 200 || payload.status === 'success'); +} diff --git a/src/assets/scss/app.scss b/src/assets/scss/app.scss index bf297f2..8706597 100644 --- a/src/assets/scss/app.scss +++ b/src/assets/scss/app.scss @@ -94,4 +94,6 @@ @import "plugins/react-select"; @import "plugins/react-slider"; @import "plugins/multiselect"; -@import "pages/dashboard"; \ No newline at end of file +@import "pages/dashboard"; +@import "pages/rcpa"; +@import "pages/gennovadc"; \ No newline at end of file diff --git a/src/assets/scss/pages/_gennovadc.scss b/src/assets/scss/pages/_gennovadc.scss new file mode 100644 index 0000000..58a11c1 --- /dev/null +++ b/src/assets/scss/pages/_gennovadc.scss @@ -0,0 +1,67 @@ +.gennova-report-scroll { + overflow-x: auto; + width: 100%; +} + +.gennova-report-loading { + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--#{$prefix}secondary-bg) 70%, transparent); +} + +.gennova-report-table { + border-collapse: separate; + border-spacing: 0; + + th.sticky-first-col, + td.sticky-first-col { + position: sticky; + left: 0; + z-index: 2; + min-width: 120px; + background-color: var(--#{$prefix}secondary-bg); + box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1); + } + + thead th.sticky-first-col { + z-index: 4; + } + + thead th.sticky-first-col, + tr.table-secondary td.sticky-first-col { + background-color: var(--#{$prefix}table-bg); + } +} + +.transplant-summary-table { + th, + td { + border: 1px solid var(--#{$prefix}border-color); + vertical-align: middle; + } + + .ts-section-end { + border-right: 2px solid var(--#{$prefix}secondary); + } + + .ts-group-start { + border-left: 2px solid var(--#{$prefix}secondary); + } +} + +.gennova-hier-toggle { + width: 220px; +} + +.gennova-hier-menu { + min-width: 280px; +} + +.gennova-hier-options { + max-height: 240px; + overflow-y: auto; +} diff --git a/src/assets/scss/pages/_rcpa.scss b/src/assets/scss/pages/_rcpa.scss new file mode 100644 index 0000000..fe65202 --- /dev/null +++ b/src/assets/scss/pages/_rcpa.scss @@ -0,0 +1,29 @@ +.rcpa-report-grid { + height: calc(100vh - 240px); + width: 100%; + + &.ag-theme-alpine { + --ag-header-column-separator-display: none; + } +} + +.rcpa-report-loading { + position: absolute; + inset: 0; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--bs-body-bg) 70%, transparent); +} + +.header-cell { + background-color: #a6e1ec; +} + +.rcpa-report-filters { + .form-select, + .form-control { + max-width: 280px; + } +} diff --git a/src/components/EntityForm.jsx b/src/components/EntityForm.jsx index 30d48b9..7cf10a5 100644 --- a/src/components/EntityForm.jsx +++ b/src/components/EntityForm.jsx @@ -22,8 +22,13 @@ import TemplatePositionsField from '@/components/form/TemplatePositionsField'; import S3FileField from '@/components/S3FileField'; import { parseCombinedMetrics } from '@/helpers/metric'; -function toOptions(values = {}) { - return Object.entries(values).map(([value, label]) => ({ value, label })); +function toOptions(values = {}, pinFirst) { + const entries = Object.entries(values); + const mapped = (list) => list.map(([value, label]) => ({ value, label })); + if (pinFirst == null || pinFirst === '') return mapped(entries); + const pinned = entries.filter(([value]) => String(value) === String(pinFirst)); + const rest = entries.filter(([value]) => String(value) !== String(pinFirst)); + return mapped([...pinned, ...rest]); } function defaultValue(field, item) { @@ -340,7 +345,7 @@ function FieldControl({ field, control, register, watch, moduleName, onUploading return ; } if (field.type === 'multiselect') { - const options = toOptions(field.values); + const options = toOptions(field.values, field.exclusiveValue || field.pinFirst); return ( spec.row(item, formatCsvDate))]; + const rows = + typeof spec.build === 'function' + ? spec.build(rowsSource, formatCsvDate) + : [spec.headers, ...rowsSource.map((item) => spec.row(item, formatCsvDate))]; downloadCsv(rows, spec.filename || `${config.module}.csv`); setMessage('Export downloaded'); } catch (err) { diff --git a/src/layouts/menu.js b/src/layouts/menu.js index 3cd3f9a..9c744eb 100644 --- a/src/layouts/menu.js +++ b/src/layouts/menu.js @@ -84,7 +84,7 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email icon: LuUsers, children: [ child('contact-list', 'Contacts', '/contacts', 'contact_get_all'), - child('contact-export', 'Contact Export', '/contactexports', 'contact_get_all'), + child('contact-export', 'Contact Export', '/contact/export', 'contact_get_all'), child('contact-attrs', 'Attributes', '/contactattributes', 'contactattribute_get_all'), child('contact-lists', 'Contact Lists', '/contactlists', 'contact_list_get_all'), ].filter((item) => !item.permission || permissions.includes(item.permission)), diff --git a/src/modules/contactexports.js b/src/modules/contactexports.js index 9699f8c..11eb2e7 100644 --- a/src/modules/contactexports.js +++ b/src/modules/contactexports.js @@ -12,8 +12,9 @@ const contactexports = { module: 'contact_exports', title: 'Contact Exports', subtitle: 'Contact Management', - baseUrl: '/contactexport', - listPath: '/contactexports', + baseUrl: '/contact', + listPath: '/contact/export', + addPath: '/contact/exportcreate', skipEdit: true, deleteMethod: 'post', afterSaveMessage: 'Export request created. Refresh the list in a few moments to download the file when ready.', @@ -30,7 +31,7 @@ const contactexports = { { key: 'createdAt', title: 'Created Date', type: 'datetime' }, ], listButtons: [ - { title: 'Add New', to: '/contactexport/addnew' }, + { title: 'Create Export', to: '/contact/exportcreate' }, { title: 'Refresh', action: 'reload' }, ], mapItems: (items) => @@ -45,6 +46,8 @@ const contactexports = { (item) => `${item.name} (${item.code})`, (item) => item.code, ); + const positionValues = { ...positions }; + delete positionValues.all; return [ { key: 'position_code', @@ -53,14 +56,14 @@ const contactexports = { required: true, exclusiveValue: 'all', fullwidth: true, - values: { all: 'All positions', ...positions }, + values: { all: 'All Position Code', ...positionValues }, }, { key: 'export_status', title: 'Contact Status', type: 'select', required: true, - values: { active: 'Active', disabled: 'Disabled' }, + values: { active: 'Active', disabled: 'Disabled', deleted: 'Deleted' }, }, ]; }, diff --git a/src/modules/contacts.js b/src/modules/contacts.js index cd60898..c212de2 100644 --- a/src/modules/contacts.js +++ b/src/modules/contacts.js @@ -31,7 +31,7 @@ const contacts = { listButtons: [ { title: 'Add New', to: '/contact/addnew' }, { title: 'Import Contacts', to: '/contact/import' }, - { title: 'Export', to: '/contactexports' }, + { title: 'Export', to: '/contact/export' }, ], formFields: async () => { const [attributesRes, positions] = await Promise.all([ diff --git a/src/modules/formentitys.js b/src/modules/formentitys.js index 6abbfcb..e4bc811 100644 --- a/src/modules/formentitys.js +++ b/src/modules/formentitys.js @@ -1,5 +1,5 @@ import { signedGet, signedPost, isSuccess } from '@/api/client'; -import { dropdownMap } from '@/api/dropdowns'; +import { dropdownMap, fetchUserAttributes } from '@/api/dropdowns'; import { splitHash } from '@/helpers/metric'; const STATUS = { active: 'Active', disabled: 'Disabled' }; @@ -56,11 +56,11 @@ const formentitys = { { key: 'status', title: 'Status', type: 'select' }, ], formFields: async () => { - const [usersRes, roles, contactRes, userAttrRes, workflowRes] = await Promise.all([ + const [usersRes, roles, contactRes, userAttrItems, workflowRes] = await Promise.all([ signedGet('ums', 'users/dropdown'), dropdownMap('ums', 'roles', (item) => item.name), signedGet('contact', 'contactattribute/get_all'), - signedGet('ums', 'userattribute/get_all'), + fetchUserAttributes(), signedGet('workflow', 'workflow_definition/get_all'), ]); const approvers = { @@ -83,7 +83,6 @@ const formentitys = { if (attr?.code) contactAttributes[attr.code] = attr.attribute || attr.code; }); const userAttributes = {}; - const userAttrItems = isSuccess(userAttrRes) ? userAttrRes.data?.items || [] : []; userAttrItems.forEach((attr) => { if (!attr?.code || attr.status === 'disabled') return; userAttributes[attr.code] = attr.attribute || attr.code; diff --git a/src/modules/leaderboards.js b/src/modules/leaderboards.js index 03e2682..fe5e0de 100644 --- a/src/modules/leaderboards.js +++ b/src/modules/leaderboards.js @@ -1,5 +1,5 @@ import { signedGet, isSuccess } from '@/api/client'; -import { dropdownMap } from '@/api/dropdowns'; +import { dropdownMap, fetchUserAttributes } from '@/api/dropdowns'; import { INTERVAL_VALUES, metricOptions, @@ -93,11 +93,17 @@ const leaderboards = { sort_order: item.sort_order ?? 0, })), formFields: async () => { - const [metrics, roles, teams] = await Promise.all([ + const [metrics, roles, teams, userAttrItems] = await Promise.all([ metricOptions(), dropdownMap('ums', 'roles', (item) => item.name), teamOptions(), + fetchUserAttributes(), ]); + const userAttributes = {}; + userAttrItems.forEach((attr) => { + if (!attr?.code || attr.status === 'disabled') return; + userAttributes[attr.code] = attr.attribute || attr.code; + }); return [ { key: 'name', title: 'Name', type: 'text', required: true }, { key: 'sort_order', title: 'Sort Order', type: 'number', required: true }, @@ -151,6 +157,15 @@ const leaderboards = { values: { all: 'All', loggedin: 'Logged In' }, }, { key: 'show_units_export', title: 'Show Units Export', type: 'select', values: YES_NO }, + { key: 'show_user_attributes', title: 'Show User Attributes', type: 'yesno', required: true, default: 'no' }, + { + key: 'user_attributes', + title: 'Include User Attributes', + type: 'multiselect', + values: userAttributes, + default: [], + showIf: { show_user_attributes: 'yes' }, + }, { key: 'show_normal_metrics', title: 'Show Normal Metrics', type: 'select', values: YES_NO }, { key: 'show_combined_metrics', title: 'Show Combined Metrics', type: 'select', values: YES_NO }, { key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS }, @@ -167,6 +182,11 @@ const leaderboards = { next.show_positions = String(next.show_positions || 'no').toLowerCase(); next.show_positions_type = next.show_positions_type || 'all'; next.show_units_export = next.show_units_export === 'no' ? 'no' : 'yes'; + next.show_user_attributes = ['yes', '1', 'true', true, 1].includes(next.show_user_attributes) ? 'yes' : 'no'; + next.user_attributes = asArray(next.user_attributes); + if (next.user_attributes.length && next.show_user_attributes !== 'yes') { + next.show_user_attributes = 'yes'; + } next.show_normal_metrics = next.show_normal_metrics === 'no' ? 'no' : 'yes'; next.show_combined_metrics = next.show_combined_metrics === 'no' ? 'no' : 'yes'; next.total_units_name = next.total_units_name || ''; @@ -207,6 +227,8 @@ const leaderboards = { payload.intervals = asArray(payload.intervals); payload.leaderboard_team_ids = leaderboardType === 'team' ? asArray(payload.leaderboard_team_ids) : []; payload.show_units_export = payload.show_units_export === 'no' ? 'no' : 'yes'; + payload.show_user_attributes = payload.show_user_attributes === 'yes' ? 'yes' : 'no'; + payload.user_attributes = asArray(payload.user_attributes); payload.show_normal_metrics = payload.show_normal_metrics === 'no' ? 'no' : 'yes'; payload.show_combined_metrics = payload.show_combined_metrics === 'no' ? 'no' : 'yes'; payload.total_units_name = payload.total_units_name || ''; diff --git a/src/modules/metriccampaigns.js b/src/modules/metriccampaigns.js index 2d54aa6..38f3ed7 100644 --- a/src/modules/metriccampaigns.js +++ b/src/modules/metriccampaigns.js @@ -1,5 +1,4 @@ -import { signedGet, isSuccess } from '@/api/client'; -import { dropdownMap } from '@/api/dropdowns'; +import { dropdownMap, fetchUserAttributes } from '@/api/dropdowns'; import { CAMPAIGN_COMBINED_TYPES, INTERVAL_VALUES, @@ -96,14 +95,13 @@ const metriccampaigns = { teams_rank: item.teams_rank ? String(item.teams_rank).toLowerCase().slice(0, 2) : '', })), formFields: async () => { - const [roles, metrics, attrsRes] = await Promise.all([ + const [roles, metrics, attrItems] = await Promise.all([ dropdownMap('ums', 'roles', (item) => item.name), metricOptions(), - signedGet('ums', 'userattribute/get_all'), + fetchUserAttributes(), ]); const attributes = {}; const attributesMeta = {}; - const attrItems = isSuccess(attrsRes) ? attrsRes.data?.items || [] : []; attrItems.forEach((attr) => { if ((attr.status || 'active') === 'disabled') return; const code = attr.code || ''; @@ -178,6 +176,15 @@ const metriccampaigns = { values: { all: 'All', loggedin: 'Logged In' }, }, { key: 'show_units_export', title: 'Show Units Export', type: 'select', values: YES_NO }, + { key: 'show_user_attributes', title: 'Show User Attributes', type: 'yesno', required: true, default: 'no' }, + { + key: 'user_attributes', + title: 'Include User Attributes', + type: 'multiselect', + values: attributes, + default: [], + showIf: { show_user_attributes: 'yes' }, + }, { key: 'show_normal_metrics', title: 'Show Normal Metrics', type: 'select', values: YES_NO }, { key: 'show_combined_metrics', title: 'Show Combined Metrics', type: 'select', values: YES_NO }, { key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS }, @@ -226,6 +233,11 @@ const metriccampaigns = { next.manager_details = asArray(next.manager_details); if (!next.manager_details.length) next.manager_details = ['l1manager']; next.show_units_export = next.show_units_export === 'no' ? 'no' : 'yes'; + next.show_user_attributes = ['yes', '1', 'true', true, 1].includes(next.show_user_attributes) ? 'yes' : 'no'; + next.user_attributes = asArray(next.user_attributes); + if (next.user_attributes.length && next.show_user_attributes !== 'yes') { + next.show_user_attributes = 'yes'; + } next.show_normal_metrics = next.show_normal_metrics === 'no' ? 'no' : 'yes'; next.show_combined_metrics = next.show_combined_metrics === 'no' ? 'no' : 'yes'; next.total_units_name = next.total_units_name || ''; @@ -277,6 +289,8 @@ const metriccampaigns = { intervals: asArray(data.intervals), manager_details: asArray(data.manager_details).length ? asArray(data.manager_details) : ['l1manager'], show_units_export: data.show_units_export === 'no' ? 'no' : 'yes', + show_user_attributes: data.show_user_attributes === 'yes' ? 'yes' : 'no', + user_attributes: asArray(data.user_attributes), show_normal_metrics: data.show_normal_metrics === 'no' ? 'no' : 'yes', show_combined_metrics: data.show_combined_metrics === 'no' ? 'no' : 'yes', total_units_name: data.total_units_name || '', diff --git a/src/modules/metricviews.js b/src/modules/metricviews.js index 300b6e2..c1b6940 100644 --- a/src/modules/metricviews.js +++ b/src/modules/metricviews.js @@ -1,5 +1,5 @@ import { signedGet, isSuccess } from '@/api/client'; -import { dropdownMap } from '@/api/dropdowns'; +import { dropdownMap, fetchUserAttributes } from '@/api/dropdowns'; import { DEFAULT_INTERVAL_VALUES, INTERVAL_VALUES, @@ -48,11 +48,11 @@ const metricviews = { sort_order: item.sort_order ?? item.widget_sort_order ?? 0, })), formFields: async () => { - const [metrics, roles, contactRes, userAttrRes] = await Promise.all([ + const [metrics, roles, contactRes, userAttrItems] = await Promise.all([ metricOptions(), dropdownMap('ums', 'roles', (item) => item.name), signedGet('contact', 'contactattribute/get_all'), - signedGet('ums', 'userattribute/get_all'), + fetchUserAttributes(), ]); const contactAttributes = {}; const contactItems = isSuccess(contactRes) ? contactRes.data?.items || [] : []; @@ -60,7 +60,6 @@ const metricviews = { if (attr?.code) contactAttributes[attr.code] = attr.attribute || attr.code; }); const userAttributes = {}; - const userAttrItems = isSuccess(userAttrRes) ? userAttrRes.data?.items || [] : []; userAttrItems.forEach((attr) => { if (!attr?.code || attr.status === 'disabled') return; userAttributes[attr.code] = attr.attribute || attr.code; diff --git a/src/modules/milestones.js b/src/modules/milestones.js index 357bed2..c7991ef 100644 --- a/src/modules/milestones.js +++ b/src/modules/milestones.js @@ -1,5 +1,5 @@ import { signedGet, isSuccess } from '@/api/client'; -import { dropdownMap } from '@/api/dropdowns'; +import { dropdownMap, fetchUserAttributes } from '@/api/dropdowns'; import { metricOptions, toDateInput } from '@/helpers/metric'; const STATUS = { active: 'Active', disabled: 'Disabled' }; @@ -159,7 +159,7 @@ const milestones = { { key: 'status', title: 'Status', type: 'select' }, ], formFields: async () => { - const [metrics, roles, badges, categories, images] = await Promise.all([ + const [metrics, roles, badges, categories, images, userAttrItems] = await Promise.all([ metricOptions(), dropdownMap('ums', 'roles', (item) => item.name), getAllItems('recognition', 'badge_list/get_all').then(titleMap), @@ -171,7 +171,13 @@ const milestones = { }); return values; }), + fetchUserAttributes(), ]); + const userAttributes = {}; + userAttrItems.forEach((attr) => { + if (!attr?.code || attr.status === 'disabled') return; + userAttributes[attr.code] = attr.attribute || attr.code; + }); return [ { key: 'title', title: 'Title', type: 'text', required: true }, { key: 'interval_start_date', title: 'Interval Start Date', type: 'date', required: true }, @@ -202,6 +208,15 @@ const milestones = { values: { all: 'All', loggedin: 'Logged In' }, }, { key: 'show_units_export', title: 'Show Units Export', type: 'select', values: YES_NO }, + { key: 'show_user_attributes', title: 'Show User Attributes', type: 'yesno', required: true, default: 'no' }, + { + key: 'user_attributes', + title: 'Include User Attributes', + type: 'multiselect', + values: userAttributes, + default: [], + showIf: { show_user_attributes: 'yes' }, + }, { key: 'show_normal_metrics', title: 'Show Normal Metrics', type: 'select', values: YES_NO }, { key: 'show_combined_metrics', title: 'Show Combined Metrics', type: 'select', values: YES_NO }, { key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS }, @@ -353,6 +368,11 @@ const milestones = { next.body = reportConfig.body || ''; next.send_report = fromBool(next.send_report ?? false); next.show_units_export = next.show_units_export === 'no' ? 'no' : 'yes'; + next.show_user_attributes = ['yes', '1', 'true', true, 1].includes(next.show_user_attributes) ? 'yes' : 'no'; + next.user_attributes = toStringList(next.user_attributes); + if (next.user_attributes.length && next.show_user_attributes !== 'yes') { + next.show_user_attributes = 'yes'; + } next.show_normal_metrics = next.show_normal_metrics === 'no' ? 'no' : 'yes'; next.show_combined_metrics = next.show_combined_metrics === 'no' ? 'no' : 'yes'; next.manager_details = toStringList(next.manager_details); @@ -389,6 +409,8 @@ const milestones = { body: (payload.body || '').trim(), }; payload.show_units_export = payload.show_units_export === 'no' ? 'no' : 'yes'; + payload.show_user_attributes = payload.show_user_attributes === 'yes' ? 'yes' : 'no'; + payload.user_attributes = toStringList(payload.user_attributes); payload.show_normal_metrics = payload.show_normal_metrics === 'no' ? 'no' : 'yes'; payload.show_combined_metrics = payload.show_combined_metrics === 'no' ? 'no' : 'yes'; payload.manager_details = toStringList(payload.manager_details).length diff --git a/src/modules/rcpacompetitors.js b/src/modules/rcpacompetitors.js index 2cf076d..7a0d6e7 100644 --- a/src/modules/rcpacompetitors.js +++ b/src/modules/rcpacompetitors.js @@ -20,6 +20,7 @@ const rcpacompetitors = { { title: 'Back to Products', to: '/rcpaproducts' }, ], formFields: () => [ + { key: 'product_id', title: 'Product', type: 'hidden' }, { key: 'company', title: 'Company', type: 'text', required: true }, { key: 'brand', title: 'Brand', type: 'text', required: true }, { key: 'title', title: 'Product Name', type: 'text', required: true }, @@ -31,12 +32,22 @@ const rcpacompetitors = { { key: 'nrv_value', title: 'NRV Value', type: 'number', required: true }, { key: 'sort_order', title: 'Sort Order', type: 'number', required: true }, ], - beforeForm: (item, _mode, ctx) => ({ - ...item, - product_id: item.product_id || ctx?.searchParams?.get('product_id') || '', - }), + beforeForm: (item, _mode, ctx) => { + const fromUrl = ctx?.searchParams?.get('product_id') || ''; + const stored = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('rcpa_product_id') || '' : ''; + const product_id = item.product_id || fromUrl || stored; + if (product_id && typeof sessionStorage !== 'undefined') { + sessionStorage.setItem('rcpa_product_id', product_id); + } + return { ...item, product_id }; + }, beforeSave: (data) => { - const payload = { ...data }; + const stored = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('rcpa_product_id') || '' : ''; + const product_id = data.product_id || stored; + if (product_id && typeof sessionStorage !== 'undefined') { + sessionStorage.setItem('rcpa_product_id', product_id); + } + const payload = { ...data, product_id }; if (!payload.product_id) delete payload.product_id; return payload; }, diff --git a/src/modules/rcpaforms.js b/src/modules/rcpaforms.js index cf54750..53be47e 100644 --- a/src/modules/rcpaforms.js +++ b/src/modules/rcpaforms.js @@ -21,7 +21,15 @@ const rcpaforms = { baseUrl: '/rcpaform', listPath: '/rcpaforms', listFields: [ - { key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] }, + { + key: 'actions', + title: 'Actions', + buttons: [ + 'edit', + 'delete', + { name: 'questions', to: (row) => `/formquestions?id=${row.id}`, title: 'Questions' }, + ], + }, { key: 'title', title: 'Title', type: 'text' }, { key: 'workflow', title: 'Workflow', type: 'text' }, { key: 'status', title: 'Status', type: 'select' }, diff --git a/src/modules/userattributes.js b/src/modules/userattributes.js index d0c02ed..b19956b 100644 --- a/src/modules/userattributes.js +++ b/src/modules/userattributes.js @@ -1,5 +1,7 @@ import { dropdownMap } from '@/api/dropdowns'; +const OPTION_TYPES = ['select', 'multiselect', 'checkbox', 'radio']; + const userattributes = { service: 'ums', module: 'userattribute', @@ -40,7 +42,7 @@ const userattributes = { { key: 'roles', title: 'Roles', type: 'multiselect', values: roles }, { key: 'sort_order', title: 'Sort Order', type: 'number', required: true, default: 0 }, { key: 'status', title: 'Status', type: 'select', required: true, values: { active: 'Active', disabled: 'Disabled' } }, - { key: 'options', title: 'Options', type: 'mapping', fullwidth: true }, + { key: 'options', title: 'Options', type: 'mapping', fullwidth: true, showIf: { type: OPTION_TYPES } }, ]; }, beforeForm: (item) => ({ @@ -52,11 +54,10 @@ const userattributes = { sort_order: item.sort_order ?? 0, }), beforeSave: (data) => { - const optionTypes = ['select', 'multiselect', 'checkbox', 'radio']; return { ...data, roles: Array.isArray(data.roles) ? data.roles : [], - options: optionTypes.includes(data.type) ? data.options || [] : [], + options: OPTION_TYPES.includes(data.type) ? data.options || [] : [], }; }, }; diff --git a/src/modules/users.js b/src/modules/users.js index 8dc1fc0..584d6b7 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -2,6 +2,105 @@ import { signedGet, isSuccess } from '@/api/client'; import { dropdownMap } from '@/api/dropdowns'; import { formatRoleNames } from '@/helpers/roles'; +const EXPORT_COLUMNS = [ + { key: 'id', title: 'Id' }, + { key: 'employee_code', title: 'Employee Code' }, + { key: 'firstname', title: 'Firstname' }, + { key: 'lastname', title: 'Lastname' }, + { key: 'email', title: 'Email' }, + { key: 'logged_in', title: 'Is Logged In' }, + { key: 'roles', title: 'Roles' }, + { key: 'phone', title: 'Phone' }, + { key: 'designation', title: 'Designation' }, + { key: 'state', title: 'State' }, + { key: 'city', title: 'City' }, + { key: 'hierarchy', title: 'Hierarchy' }, + { key: 'status', title: 'Status' }, + { key: 'createdAt', title: 'Created At' }, + { key: 'updatedAt', title: 'Updated At' }, +]; + +const EXPORT_SKIP = new Set(['avatar', 'channels', 'domain', 'partition', 'user_id']); +const EXPORT_FIXED_KEYS = new Set([ + ...EXPORT_COLUMNS.map((column) => column.key), + 'created_at', + 'updated_at', +]); + +function formatExportHeading(key) { + return String(key || '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .trim() + .replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + +function extraExportKeys(items) { + const keys = []; + const seen = new Set(); + (items || []).forEach((item) => { + if (!item || typeof item !== 'object') return; + Object.keys(item).forEach((key) => { + if (EXPORT_SKIP.has(key) || EXPORT_FIXED_KEYS.has(key) || seen.has(key)) return; + seen.add(key); + keys.push(key); + }); + }); + return keys; +} + +function formatHierarchy(value) { + if (value == null || value === '') return '-'; + if (Array.isArray(value)) { + const parts = value + .map((part) => { + if (part == null || part === '') return ''; + if (typeof part === 'object') return part.name || part.code || part.id || ''; + return String(part); + }) + .filter(Boolean); + return parts.join(' > ') || '-'; + } + if (typeof value === 'object') { + if (value.name && value.code) return `${value.name} (${value.code})`; + return value.name || value.code || value.id || JSON.stringify(value); + } + return String(value); +} + +function exportCell(value, formatDate, key) { + if (value == null || value === '') return '-'; + if (Array.isArray(value)) { + if (!value.length) return '-'; + if (value.some((part) => part && typeof part === 'object' && (part.name || part.id))) { + return formatRoleNames(value) || '-'; + } + return value + .map((part) => (part && typeof part === 'object' ? JSON.stringify(part) : part)) + .filter((part) => part !== null && part !== undefined && part !== '') + .join(', ') || '-'; + } + if (typeof value === 'object') { + if (value.name) return value.name; + if (value.id) return value.id; + return JSON.stringify(value); + } + if (formatDate && /(?:At|_at|Date|_date)$/.test(String(key || ''))) { + return formatDate(value); + } + return String(value); +} + +function exportColumnValue(user, key, formatDate) { + if (key === 'roles') return user.roles ? formatRoleNames(user.roles) || '-' : '-'; + if (key === 'hierarchy') return formatHierarchy(user.hierarchy); + if (key === 'createdAt') return user.createdAt || user.created_at ? formatDate(user.createdAt || user.created_at) : '-'; + if (key === 'updatedAt') return user.updatedAt || user.updated_at ? formatDate(user.updatedAt || user.updated_at) : '-'; + const value = user?.[key]; + if (value == null || value === '') return '-'; + return String(value); +} + const users = { service: 'ums', module: 'users', @@ -33,36 +132,15 @@ const users = { exportCsv: { params: { start: 0, length: 9999, search: JSON.stringify({}), keyword: '', export: 1 }, filename: 'users.csv', - headers: [ - 'id', - 'Employee Code', - 'Firstname', - 'Lastname', - 'Email', - 'Is Logged In', - 'Roles', - 'Phone', - 'Designation', - 'State', - 'City', - 'Status', - 'Created At', - ], - row: (user, formatDate) => [ - user.id ?? '-', - user.employee_code ?? '-', - user.firstname ?? '-', - user.lastname ?? '-', - user.email ?? '-', - user.logged_in ?? '-', - user.roles ? formatRoleNames(user.roles) || '-' : '-', - user.phone ?? '-', - user.designation ?? '-', - user.state ?? '-', - user.city ?? '-', - user.status ?? '-', - user.createdAt ? formatDate(user.createdAt) : '-', - ], + build: (items, formatDate) => { + const extras = extraExportKeys(items); + const headers = [...EXPORT_COLUMNS.map((column) => column.title), ...extras.map(formatExportHeading)]; + const rows = (items || []).map((user) => [ + ...EXPORT_COLUMNS.map((column) => exportColumnValue(user, column.key, formatDate)), + ...extras.map((key) => exportCell(user?.[key], formatDate, key)), + ]); + return [headers, ...rows]; + }, }, formFields: async () => { const domainRes = await signedGet('ums', 'domain/get_one'); diff --git a/src/routes/index.jsx b/src/routes/index.jsx index 3e47784..d5889eb 100644 --- a/src/routes/index.jsx +++ b/src/routes/index.jsx @@ -85,13 +85,23 @@ const MagMediaViewers = lazy(() => import('@/views/content/MagMediaViewers')); const WorkflowEditor = lazy(() => import('@/views/workflow/Editor')); const WorkflowMonitor = lazy(() => import('@/views/workflow/Monitor')); const LeaderboardDashboard = lazy(() => import('@/views/leaderboard/Dashboard')); +const NebReportList = lazy(() => import('@/views/neblogistic/ReportList')); +const NebReportDetail = lazy(() => import('@/views/neblogistic/ReportDetail')); +const NebDepotPage = lazy(() => import('@/views/neblogistic/DepotPage')); +const NebMappingPage = lazy(() => import('@/views/neblogistic/MappingPage')); +const NebImportPage = lazy(() => import('@/views/neblogistic/ImportPage')); +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')); function moduleRoutes(config) { const routes = [ { path: config.listPath.replace(/^\//, ''), element: }, ]; if (!config.skipAdd) { - routes.push({ path: `${config.baseUrl.replace(/^\//, '')}/addnew`, element: }); + const addPath = (config.addPath || `${config.baseUrl}/addnew`).replace(/^\//, ''); + routes.push({ path: addPath, element: }); } if (!config.skipEdit) { routes.push({ path: `${config.baseUrl.replace(/^\//, '')}/edit`, element: }); @@ -132,6 +142,8 @@ export const routes = [ { path: '/team/players', element: }, ...moduleRoutes(contacts), ...moduleRoutes(contactexports), + { path: 'contactexports', element: }, + { path: 'contactexport/addnew', element: }, ...moduleRoutes(contactattributes), { path: '/contact/import', @@ -204,6 +216,15 @@ export const routes = [ ...moduleRoutes(tracks), { path: '/metricreports', element: }, { path: '/metricreport/report', element: }, + { path: '/neblogisticreports', element: }, + { path: '/neblogisticreport/viewreportbyid', element: }, + { path: '/neblogisticreport/depot', element: }, + { path: '/neblogisticreport/import', element: }, + { path: '/depotwithtmmappings', element: }, + { path: '/rcpareport/tmwise', element: }, + { path: '/rcpareport/monthwise', element: }, + { path: '/rcpareport/formwise', element: }, + { path: '/gennovadcreport/dashboard', element: }, { path: '/coming-soon', element: }, { path: '*', element: }, ], diff --git a/src/views/gennovadcreport/Dashboard.jsx b/src/views/gennovadcreport/Dashboard.jsx new file mode 100644 index 0000000..f037c36 --- /dev/null +++ b/src/views/gennovadcreport/Dashboard.jsx @@ -0,0 +1,188 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Container, Form } from 'react-bootstrap'; +import { isSuccess, signedGet } from '@/api/client'; +import { useAuth } from '@/auth/AuthProvider'; +import PageBreadcrumb from '@/components/PageBreadcrumb'; +import HierarchyFilter from './HierarchyFilter'; +import ReportSection from './ReportSection'; +import { + buildActivitySummary, + buildCenterSummary, + buildMonthlySummary, + buildTransplantPenetration, + buildTransplantSummary, + buildTrendSummary, + buildWeeklySummary, + currentMonth, + HIERARCHY_PARTITION, + REPORT_TIMEOUT, + reportData, + reportMonths, +} from './helpers'; + +const SECTIONS = [ + { + id: 'monthly', + title: (month) => `Monthly Summary (${month})`, + path: 'gennovadc_reports/monthly_summary', + build: buildMonthlySummary, + filename: 'monthsummary-report.xlsx', + }, + { + id: 'weekly', + title: (month) => `Weekly Summary (${month})`, + path: 'gennovadc_reports/weekly_summary', + build: buildWeeklySummary, + filename: 'weekly-summary.xlsx', + }, + { + id: 'center', + title: () => 'Center Summary', + path: 'gennovadc_reports/center_summary', + build: buildCenterSummary, + filename: 'center-summary.xlsx', + }, + { + id: 'centerTrend', + title: () => 'Center Trend Summary', + path: 'gennovadc_reports/center_trend_summary', + build: buildTrendSummary, + filename: 'center-trend-summary.xlsx', + monthless: true, + }, + { + id: 'unitTrend', + title: () => 'Unit Trend Summary', + path: 'gennovadc_reports/unit_trend_summary', + build: buildTrendSummary, + filename: 'unit-trend-summary.xlsx', + monthless: true, + }, + { + id: 'transplantPenetration', + title: () => 'Transplant Penetration', + path: 'gennovadc_reports/transplant_penetration', + build: buildTransplantPenetration, + filename: 'transplant-penetration.xlsx', + sticky: true, + }, + { + id: 'transplantSummary', + title: () => 'Transplant Summary', + path: 'gennovadc_reports/transplant_summary', + build: buildTransplantSummary, + filename: 'transplant-summary.xlsx', + sticky: true, + }, + { + id: 'activity', + title: () => 'Activity Summary', + path: 'gennovadc_reports/activity_summary', + build: buildActivitySummary, + filename: 'activity-summary.xlsx', + sticky: true, + }, +]; + +const MONTH_SECTIONS = SECTIONS.filter((section) => !section.monthless); +const TREND_SECTIONS = SECTIONS.filter((section) => section.monthless); +const EMPTY_TABLE = { columns: [], rows: [], total: null }; + +export default function GennovadcDashboard() { + const { hasPermission } = useAuth(); + const allDataAccess = hasPermission('all_data_access'); + const months = useMemo(() => reportMonths(), []); + const [month, setMonth] = useState(currentMonth); + const [hierarchies, setHierarchies] = useState([]); + const [hierarchyError, setHierarchyError] = useState(''); + const [selectedHier, setSelectedHier] = useState([]); + const [results, setResults] = useState({}); + + const hierarchyIds = useMemo(() => JSON.stringify(selectedHier), [selectedHier]); + + useEffect(() => { + const loadHierarchies = async () => { + const params = { partition: HIERARCHY_PARTITION }; + if (allDataAccess) params.hierarchy_id = 'root'; + const response = await signedGet('ums', 'hierarchy/hierarchy_below_me', params, { timeout: REPORT_TIMEOUT }); + if (!isSuccess(response)) { + setHierarchyError(response?.message || 'Unable to load hierarchy'); + 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); + }; + loadHierarchies(); + }, [allDataAccess]); + + const loadSections = useCallback((sections, params, isCancelled) => { + setResults((prev) => { + const next = { ...prev }; + sections.forEach((section) => { + next[section.id] = { ...prev[section.id], loading: true, error: '' }; + }); + return next; + }); + sections.forEach(async (section) => { + const response = await signedGet('reports', section.path, params, { timeout: REPORT_TIMEOUT }); + if (isCancelled()) return; + const ok = isSuccess(response) || response?.body !== undefined; + setResults((prev) => ({ + ...prev, + [section.id]: ok + ? { loading: false, error: '', table: section.build(reportData(response)) } + : { loading: false, error: response?.message || 'Unable to load report', table: EMPTY_TABLE }, + })); + }); + }, []); + + useEffect(() => { + let cancelled = false; + loadSections(MONTH_SECTIONS, { month, hierarchy_ids: hierarchyIds }, () => cancelled); + return () => { + cancelled = true; + }; + }, [month, hierarchyIds, loadSections]); + + useEffect(() => { + let cancelled = false; + loadSections(TREND_SECTIONS, { hierarchy_ids: hierarchyIds }, () => cancelled); + return () => { + cancelled = true; + }; + }, [hierarchyIds, loadSections]); + + return ( + +
+ +
+ + setMonth(event.target.value)} style={{ width: 170 }}> + {months.map((option) => ( + + ))} + +
+
+ {hierarchyError ?
{hierarchyError}
: null} + {SECTIONS.map((section, index) => { + const result = results[section.id] || {}; + return ( + + ); + })} +
+ ); +} diff --git a/src/views/gennovadcreport/HierarchyFilter.jsx b/src/views/gennovadcreport/HierarchyFilter.jsx new file mode 100644 index 0000000..29911ce --- /dev/null +++ b/src/views/gennovadcreport/HierarchyFilter.jsx @@ -0,0 +1,73 @@ +import { useMemo, useState } from 'react'; +import { Button, Dropdown, Form } from 'react-bootstrap'; + +export default function HierarchyFilter({ options, selected, onApply, disabled }) { + 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.name} (${option.type})`.toLowerCase().includes(term)); + }, [options, search]); + + const toggleOpen = (next) => { + if (next) { + setDraft(selected); + setSearch(''); + } + setShow(next); + }; + + const toggleOption = (id) => { + setDraft((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id])); + }; + + const apply = () => { + onApply(draft); + setShow(false); + }; + + return ( + + + {selected.length ? `Hierarchy (${selected.length})` : 'Hierarchy'} + + + setSearch(event.target.value)} + /> +
+ {visible.length ? ( + visible.map((option) => ( + toggleOption(option.id)} + /> + )) + ) : ( +
No hierarchy found
+ )} +
+
+
+ + +
+
+
+ ); +} diff --git a/src/views/gennovadcreport/ReportSection.jsx b/src/views/gennovadcreport/ReportSection.jsx new file mode 100644 index 0000000..98227ac --- /dev/null +++ b/src/views/gennovadcreport/ReportSection.jsx @@ -0,0 +1,104 @@ +import { useState } from 'react'; +import { Button, Card, Spinner, Table } from 'react-bootstrap'; +import clsx from 'clsx'; +import { exportTableToXlsx } from './exportXlsx'; +import { formatCell, headerRows, rowValue } from './helpers'; + +export default function ReportSection({ title, table, loading, error, filename, stickyFirstColumn }) { + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(''); + const { columns, rows, total, bordered } = table; + const headers = headerRows(columns); + + const handleExport = async () => { + setExporting(true); + setExportError(''); + try { + await exportTableToXlsx(table, filename); + } catch (err) { + setExportError(err.message || 'Unable to export report'); + } finally { + setExporting(false); + } + }; + + const cellClass = (index) => + clsx(stickyFirstColumn && index === 0 && 'sticky-first-col', columns[index]?.className); + + return ( + + +
{title}
+ +
+ + {error ?
{error}
: null} + {exportError ?
{exportError}
: null} + {loading ? ( +
+ +
+ ) : null} + {columns.length ? ( +
+ + + {headers.map((cells, rowIndex) => ( + + {cells.map((cell) => ( + + ))} + + ))} + + + {rows.length ? ( + rows.map((row, rowIndex) => ( + + {columns.map((column, index) => ( + + ))} + + )) + ) : ( + + + + )} + {total ? ( + + {total.map((value, index) => ( + + ))} + + ) : null} + +
1 && 'text-center')} + > + {cell.label} +
+ {formatCell(rowValue(row, column), column.format)} +
+ No data +
+ {formatCell(value, columns[index]?.format)} +
+
+ ) : ( +
{loading ? 'Loading...' : 'No data'}
+ )} +
+
+ ); +} diff --git a/src/views/gennovadcreport/exportXlsx.js b/src/views/gennovadcreport/exportXlsx.js new file mode 100644 index 0000000..6312098 --- /dev/null +++ b/src/views/gennovadcreport/exportXlsx.js @@ -0,0 +1,48 @@ +import { excelValue, headerRows, rowValue } from './helpers'; + +export async function exportTableToXlsx(table, filename) { + const { default: ExcelJS } = await import('exceljs'); + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet('Report'); + const { columns, rows, total } = table; + + const headers = headerRows(columns); + headers.forEach((cells, rowIndex) => { + const excelRow = rowIndex + 1; + cells.forEach((cell) => { + const col = cell.column + 1; + sheet.getCell(excelRow, col).value = cell.label; + if (cell.colSpan > 1 || cell.rowSpan > 1) { + sheet.mergeCells(excelRow, col, excelRow + cell.rowSpan - 1, col + cell.colSpan - 1); + } + }); + }); + for (let r = 1; r <= headers.length; r += 1) { + const row = sheet.getRow(r); + row.font = { bold: true }; + row.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; + } + + rows.forEach((row) => { + sheet.addRow(columns.map((column) => excelValue(rowValue(row, column)))); + }); + if (total) { + sheet.addRow(total.map(excelValue)).font = { bold: true }; + } + + columns.forEach((column, index) => { + sheet.getColumn(index + 1).width = Math.min(Math.max(String(column.label).length + 2, 12), 40); + }); + + 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/gennovadcreport/helpers.js b/src/views/gennovadcreport/helpers.js new file mode 100644 index 0000000..71101c1 --- /dev/null +++ b/src/views/gennovadcreport/helpers.js @@ -0,0 +1,363 @@ +export const REPORT_TIMEOUT = 120000; +export const HIERARCHY_PARTITION = 'rapport-gennovadc'; +const FIRST_REPORT_MONTH = new Date(2025, 6, 1); + +const MONTH_ABBR = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; + +export function monthName(date) { + return date.toLocaleString('en-US', { month: 'long', year: 'numeric' }); +} + +export function currentMonth() { + return monthName(new Date()); +} + +export function reportMonths() { + const months = []; + const end = new Date(); + end.setDate(1); + const cursor = new Date(FIRST_REPORT_MONTH); + while (cursor <= end) { + months.push(monthName(cursor)); + cursor.setMonth(cursor.getMonth() + 1); + } + return months.reverse(); +} + +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 toNumber(value) { + return isNumeric(value) ? Number(value) : 0; +} + +function numberFormat(value, decimals = 0) { + return Number(value).toLocaleString('en-US', { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +} + +export function formatCell(value, format = 'auto') { + if (value === null || value === undefined || value === '') return ''; + if (!isNumeric(value)) return typeof value === 'object' ? '' : String(value); + const num = Number(value); + if (format === 'int') return numberFormat(num); + return Number.isInteger(num) ? numberFormat(num) : numberFormat(num, 2); +} + +export function excelValue(value) { + if (value === null || value === undefined) return ''; + if (isNumeric(value)) return Number(value); + return typeof value === 'object' ? '' : value; +} + +function ucwords(text) { + return String(text).replace(/(^|\s)(\S)/g, (_, space, char) => space + char.toUpperCase()); +} + +function keyLabel(key) { + return ucwords(String(key).replace(/[_-]/g, ' ')); +} + +function percent(numerator, denominator) { + return denominator > 0 ? `${Math.round((numerator / denominator) * 100)}%` : '0%'; +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +/** Lambda responses are sometimes wrapped as { body: "" }. */ +export function reportData(payload) { + if (!payload) return {}; + if (payload.body !== undefined) { + let body = payload.body; + if (typeof body === 'string') { + try { + body = JSON.parse(body); + } catch { + return {}; + } + } + return body?.data || {}; + } + return payload.data || {}; +} + +function cellValue(row, key, nestedKey = null) { + const value = row && typeof row === 'object' ? row[key] : undefined; + if (nestedKey === null) return value ?? ''; + return value && typeof value === 'object' ? (value[nestedKey] ?? '') : ''; +} + +function columnsFromFirstRow(items, format) { + if (!items.length) return []; + const keys = Object.keys(items[0]); + keys.sort((a, b) => (a === 'product_name' ? -1 : b === 'product_name' ? 1 : 0)); + return keys.map((key) => ({ key, label: keyLabel(key), format })); +} + +function sumColumns(items, keys) { + const totals = {}; + keys.forEach((key) => { + totals[key] = items.reduce((sum, item) => sum + toNumber(item?.[key]), 0); + }); + return totals; +} + +export function buildMonthlySummary(data) { + const items = asArray(data.item); + const columns = columnsFromFirstRow(items, 'auto'); + const totals = sumColumns(items, columns.map((col) => col.key).filter((key) => key !== 'product_name')); + const total = items.length + ? columns.map((col, index) => { + if (index === 0) return 'Total'; + if (col.key === 'percent_ach') return percent(totals.units_ach, totals.units_plan); + return totals[col.key] ?? 0; + }) + : null; + return { columns, rows: items, total }; +} + +export function buildWeeklySummary(data) { + const items = asArray(data.item); + const columns = columnsFromFirstRow(items, 'int'); + const totals = sumColumns(items, columns.map((col) => col.key).filter((key) => key !== 'product_name')); + const total = items.length + ? columns.map((col, index) => { + if (index === 0) return 'Total'; + if (col.key === 'percent_ach') return percent(totals.units_ach, totals.month_plan); + return totals[col.key] ?? 0; + }) + : null; + return { columns, rows: items, total }; +} + +export function buildCenterSummary(data) { + const items = asArray(data.item); + const apiTotal = data.total && typeof data.total === 'object' ? data.total : null; + const columns = columnsFromFirstRow(items, 'int'); + const total = items.length && apiTotal + ? columns.map((col, index) => { + if (index === 0) return 'Total'; + if (col.key === 'percent_ach') return percent(toNumber(apiTotal.centers_ach), toNumber(apiTotal.centers_plan)); + return toNumber(apiTotal[col.key]); + }) + : null; + return { columns, rows: items, total }; +} + +/** Current month first, then the previous 11 (matches the reports API rolling window). */ +function rolling12MonthColumns() { + const now = new Date(); + const months = []; + for (let i = 0; i < 12; i += 1) { + const date = new Date(now.getFullYear(), now.getMonth() - i, 1); + months.push({ + key: `${MONTH_ABBR[date.getMonth()]}_${date.getFullYear()}`, + label: date.toLocaleString('en-US', { month: 'short', year: 'numeric' }), + }); + } + return months; +} + +function trendMonthKeyLabel(key) { + const match = /^([A-Z]{3})_(\d{4})$/.exec(key); + if (!match) return key; + const monthIndex = MONTH_ABBR.indexOf(match[1]); + if (monthIndex < 0) return key; + return new Date(Number(match[2]), monthIndex, 1).toLocaleString('en-US', { month: 'short', year: 'numeric' }); +} + +/** Use API months when present and year-aware; legacy keys like "JAN" fall back to a rolling 12 months. */ +function trendMonthColumns(data) { + const months = asArray(data.months); + const firstKey = months[0]?.key || ''; + if (firstKey && !/^[A-Z]{3}$/.test(firstKey)) return months; + return rolling12MonthColumns(); +} + +export function buildTrendSummary(data) { + const items = asArray(data.item); + const columns = [ + { key: 'product_name', label: 'Product Name', format: 'int' }, + ...trendMonthColumns(data) + .filter((month) => month?.key) + .map((month) => ({ key: month.key, label: month.label || trendMonthKeyLabel(month.key), format: 'int' })), + ]; + const apiTotal = data.total && typeof data.total === 'object' && Object.keys(data.total).length ? data.total : null; + const totals = apiTotal || sumColumns(items, columns.slice(1).map((col) => col.key)); + const total = items.length + ? columns.map((col, index) => (index === 0 ? 'Total' : toNumber(totals[col.key]))) + : null; + return { columns, rows: items, total }; +} + +const WEEKS = ['Week-1', 'Week-2', 'Week-3', 'Week-4']; + +export function buildTransplantPenetration(data) { + const items = asArray(data.transplant_penetration); + const flat = [ + ['Transplant Type', 'Transplant Type'], + ['Monthly Expected no. of Transplant', 'Monthly Expected no. of Transplant'], + ['Gennova Share Dennovo Plan', 'Gennova Share Denovo Plan'], + ['Gennova Share Switch Plan', 'Gennova Share Switch Plan'], + ['Gennova Share Graftsol Tx Plan', 'Gennova Share Graftsol Tx Plan'], + ['Total Actual Transplants', 'Total Actual Transplants'], + ]; + const groups = [ + ['Gennova Share Dennovo Actual', 'Gennova Share Denovo Actual'], + ['Gennova Share Switch Actual', 'Gennova Share Switch Actual'], + ['Gennova Share Graftsol Tx Actual', 'Gennova Share Graftsol Tx Actual'], + ]; + const columns = [ + ...flat.map(([key, label]) => ({ key, label })), + ...groups.flatMap(([key, group]) => + WEEKS.map((week) => ({ key, nestedKey: week, label: week, group })), + ), + ]; + return { columns, rows: items, total: null }; +} + +const TRANSPLANT_SUMMARY_FLAT = [ + ['Position Codes', 'Position Code'], + ['Total Transplants Plan', 'Total Transplants Expected'], + ['Total Transplants Actual', 'Total Transplants Actual'], + ['Gennova Share Denovo Plan', 'Gennova Share Denovo Plan'], + ['Gennova Share Denovo Actual', 'Gennova Share Denovo Actual'], + ['Gennova Share Switch Plan', 'Gennova Share Switch Plan'], + ['Gennova Share Switch Actual', 'Gennova Share Switch Actual'], + ['Gennova Share Graftsol Tx Plan', 'Gennova Share Graftsol Tx Plan'], + ['Gennova Share Graftsol Tx Actual', 'Gennova Share Graftsol Tx Actual'], +]; + +const TRANSPLANT_SUMMARY_GROUPS = [ + { + name: 'Kidney Transplant', + columns: [ + ['Kidney Tx Actual', 'Kidney Tx Actual'], + ['Kidney Gennova Share Denovo Actual', 'Gennova Share Denovo Actual'], + ['Kidney Gennova Share Switch Actual', 'Gennova Share Switch Actual'], + ['Kidney Gennova Share Graftsol Tx Actual', 'Gennova Share Graftsol Tx Actual'], + ], + }, + { + name: 'Liver Transplant', + columns: [ + ['Liver Tx Actual', 'Liver Tx Actual'], + ['Liver Gennova Share Denovo Actual', 'Gennova Share Denovo Actual'], + ['Liver Gennova Share Switch Actual', 'Gennova Share Switch Actual'], + ['Liver Gennova Share Graftsol Tx Actual', 'Gennova Share Graftsol Tx Actual'], + ], + }, + { + name: 'Cardioplegia', + columns: [ + ['Cardioplegia Plan', 'Cardioplegia Expected'], + ['Cardioplegia Actual', 'Cardioplegia Actual'], + ['Gennova Share Graftsol Cardioplegia', 'Gennova Share Graftsol Cardioplegia'], + ], + }, +]; + +export function buildTransplantSummary(data) { + const items = asArray(data.transplant_summary ?? data.item); + const columns = [ + ...TRANSPLANT_SUMMARY_FLAT.map(([key, label], index) => ({ + key, + label, + className: index === TRANSPLANT_SUMMARY_FLAT.length - 1 ? 'ts-section-end' : undefined, + })), + ...TRANSPLANT_SUMMARY_GROUPS.flatMap((group) => + group.columns.map(([key, label], index) => ({ + key, + label, + group: group.name, + className: index === 0 ? 'ts-group-start' : undefined, + })), + ), + ]; + return { columns, rows: items, total: null, bordered: true }; +} + +function activityLabel(text) { + return ucwords(String(text).replace(/_/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase()); +} + +function activityMonthTime(label) { + const match = /^([A-Za-z]{3})-(\d{2})$/.exec(label); + if (!match) return 0; + const monthIndex = MONTH_ABBR.indexOf(match[1].toUpperCase()); + return monthIndex < 0 ? 0 : new Date(2000 + Number(match[2]), monthIndex, 1).getTime(); +} + +const ACTIVITY_HIDDEN = ['Position_id', 'Position_owner']; +const ACTIVITY_PREFERRED = ['Product Name', '8. Activity', 'Activity', 'Month Plan', 'Month Actl', 'Month Actual']; + +export function buildActivitySummary(data) { + const items = asArray(data.activity_summary ?? data.item); + if (!items.length) return { columns: [], rows: [], total: null }; + + let flatCols = []; + const nestedCols = []; + Object.entries(items[0]).forEach(([key, value]) => { + if (ACTIVITY_HIDDEN.includes(key)) return; + if (value && typeof value === 'object') { + Object.keys(value).forEach((subKey) => { + nestedCols.push({ key, nestedKey: subKey, label: `${activityLabel(key)} - ${activityLabel(subKey)}` }); + }); + } else { + flatCols.push({ key, label: activityLabel(key) }); + } + }); + + const ordered = []; + ACTIVITY_PREFERRED.forEach((label) => { + const index = flatCols.findIndex((col) => col.label === label); + if (index >= 0) { + ordered.push(flatCols[index]); + flatCols = flatCols.filter((_, i) => i !== index); + } + }); + + const isMonth = (col) => /^[A-Za-z]{3}-\d{2}$/.test(col.label); + const months = flatCols.filter(isMonth).sort((a, b) => activityMonthTime(b.label) - activityMonthTime(a.label)); + const rest = flatCols.filter((col) => !isMonth(col)); + + return { columns: [...ordered, ...rest, ...months, ...nestedCols], rows: items, total: null }; +} + +export function rowValue(row, column) { + return cellValue(row, column.key, column.nestedKey ?? null); +} + +/** + * Header rows for a table model. Columns sharing a `group` are merged under one + * top-level cell; ungrouped columns span both rows. + */ +export function headerRows(columns) { + const hasGroups = columns.some((col) => col.group); + if (!hasGroups) { + return [columns.map((col, index) => ({ label: col.label, colSpan: 1, rowSpan: 1, column: index }))]; + } + const top = []; + const bottom = []; + columns.forEach((col, index) => { + if (!col.group) { + top.push({ label: col.label, colSpan: 1, rowSpan: 2, column: index }); + return; + } + const last = top[top.length - 1]; + if (last && last.group === col.group && last.column + last.colSpan === index) { + last.colSpan += 1; + } else { + top.push({ label: col.group, group: col.group, colSpan: 1, rowSpan: 1, column: index }); + } + bottom.push({ label: col.label, colSpan: 1, rowSpan: 1, column: index }); + }); + return [top, bottom]; +} diff --git a/src/views/neblogistic/DepotPage.jsx b/src/views/neblogistic/DepotPage.jsx new file mode 100644 index 0000000..f8ccc34 --- /dev/null +++ b/src/views/neblogistic/DepotPage.jsx @@ -0,0 +1,129 @@ +import { useEffect, useState } from 'react'; +import { Button, Card, Col, Container, Form, Row, Table } from 'react-bootstrap'; +import { TbPencil, TbTrash } from 'react-icons/tb'; +import { laravelGet, laravelPost, isLaravelSuccess } from '@/api/laravel'; +import PageBreadcrumb from '@/components/PageBreadcrumb'; +import { STATES } from './helpers'; + +const empty = { id: '', region: '', state: '', pincode: '', name: '' }; + +export default function NebDepotPage() { + const [items, setItems] = useState([]); + const [form, setForm] = useState(empty); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + const [saving, setSaving] = useState(false); + + const load = async () => { + const response = await laravelGet('neblogistic/depots'); + if (isLaravelSuccess(response)) setItems(response.data?.items || []); + else setError(response?.message || 'Unable to load depots'); + }; + + useEffect(() => { load(); }, []); + + const submit = async (event) => { + event.preventDefault(); + setSaving(true); + setError(''); + setMessage(''); + const response = await laravelPost('neblogistic/depots', form); + setSaving(false); + if (!isLaravelSuccess(response)) { + setError(response?.message || 'Save failed'); + return; + } + setMessage(response.message || 'Depot is saved'); + setForm(empty); + load(); + }; + + const remove = async (id) => { + if (!window.confirm('Are you sure you want to delete this record?')) return; + const response = await laravelPost('neblogistic/depots/delete', { id }); + if (!isLaravelSuccess(response)) { + setError(response?.message || 'Delete failed'); + return; + } + setMessage(response.message); + if (form.id === id) setForm(empty); + load(); + }; + + return ( + + + {error ?
{error}
: null} + {message ?
{message}
: null} + + +
+
+

Add / Edit Depot

+
+ + +
+
+ + + Region + setForm((p) => ({ ...p, region: e.target.value }))} /> + + + State + setForm((p) => ({ ...p, state: e.target.value }))}> + + {STATES.map((state) => )} + + + + Pincode + setForm((p) => ({ ...p, pincode: e.target.value }))} /> + + + Name + setForm((p) => ({ ...p, name: e.target.value }))} /> + + +
+
+
+ + + + + + + + + + + {items.length ? items.map((item) => ( + + + + + + + )) : ( + + )} + +
ActionRegionStatePincode
+ + + {item.region}{item.state}{item.pincode}
No records found.
+
+ ); +} diff --git a/src/views/neblogistic/ImportPage.jsx b/src/views/neblogistic/ImportPage.jsx new file mode 100644 index 0000000..dce8ff9 --- /dev/null +++ b/src/views/neblogistic/ImportPage.jsx @@ -0,0 +1,32 @@ +import { Link } from 'react-router'; +import { Button, Card, Container } from 'react-bootstrap'; +import PageBreadcrumb from '@/components/PageBreadcrumb'; + +export default function NebImportPage() { + return ( + + + + +
+

Import Neblogistic Data

+ +
+
+
Import Instructions
+
    +
  • Upload an Excel (.xlsx, .xls) or CSV (.csv) file on the Laravel portal.
  • +
  • The file must contain a column named Connect od Order.
  • +
  • Only ORDER NO, INVOICE NO, COURIER NAME, LR NO, LR DATE (and Status) are updated.
  • +
  • Maximum file size: 10MB.
  • +
+
+

+ File import still runs on Laravel because it writes the MySQL neblogistic table. + Open /neblogisticreport/import on the Laravel app to upload the file. +

+
+
+
+ ); +} diff --git a/src/views/neblogistic/MappingPage.jsx b/src/views/neblogistic/MappingPage.jsx new file mode 100644 index 0000000..812dbc0 --- /dev/null +++ b/src/views/neblogistic/MappingPage.jsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from 'react'; +import { Button, Card, Col, Container, Form, Row, Table } from 'react-bootstrap'; +import { TbPencil, TbTrash } from 'react-icons/tb'; +import { laravelGet, laravelPost, isLaravelSuccess } from '@/api/laravel'; +import PageBreadcrumb from '@/components/PageBreadcrumb'; + +const empty = { id: '', depot_id: '', emp_id_tm: '', tm_name: '', tm_email: '', hierarchy_id: '' }; + +export default function NebMappingPage() { + const [depots, setDepots] = useState([]); + const [items, setItems] = useState([]); + const [form, setForm] = useState(empty); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + const [saving, setSaving] = useState(false); + + const load = async () => { + const [depotRes, mapRes] = await Promise.all([ + laravelGet('neblogistic/depots'), + laravelGet('neblogistic/mappings'), + ]); + if (isLaravelSuccess(depotRes)) setDepots(depotRes.data?.items || []); + if (isLaravelSuccess(mapRes)) setItems(mapRes.data?.items || []); + if (!isLaravelSuccess(depotRes) || !isLaravelSuccess(mapRes)) { + setError(depotRes?.message || mapRes?.message || 'Unable to load mappings'); + } + }; + + useEffect(() => { load(); }, []); + + const submit = async (event) => { + event.preventDefault(); + setSaving(true); + setError(''); + setMessage(''); + const response = await laravelPost('neblogistic/mappings', form); + setSaving(false); + if (!isLaravelSuccess(response)) { + setError(response?.message || 'Save failed'); + return; + } + setMessage(response.message || 'Successfully Saved !!!'); + setForm(empty); + load(); + }; + + const remove = async (id) => { + if (!window.confirm('Are you sure you want to delete this record?')) return; + const response = await laravelPost('neblogistic/mappings/delete', { id }); + if (!isLaravelSuccess(response)) { + setError(response?.message || 'Delete failed'); + return; + } + setMessage(response.message); + if (form.id === id) setForm(empty); + load(); + }; + + return ( + + + {error ?
{error}
: null} + {message ?
{message}
: null} + + +
+
+

Add / Edit Mapping

+
+ + +
+
+ + + Depot + setForm((p) => ({ ...p, depot_id: e.target.value }))}> + + {depots.map((depot) => ( + + ))} + + + + Emp ID of TM + setForm((p) => ({ ...p, emp_id_tm: e.target.value }))} /> + + + Name of TM + setForm((p) => ({ ...p, tm_name: e.target.value }))} /> + + + Email of TM + setForm((p) => ({ ...p, tm_email: e.target.value }))} /> + + + Hierarchy ID + setForm((p) => ({ ...p, hierarchy_id: e.target.value }))} /> + + +
+
+
+ + + + + + + + + + + + + {items.length ? items.map((item) => ( + + + + + + + + + )) : ( + + )} + +
ActionsDepotTM Emp IDTM NameTM EmailHierarchy ID
+ + + {item.depot_name || item.depot_region || item.depot_id}{item.emp_id_tm}{item.tm_name}{item.tm_email}{item.hierarchy_id}
No records found.
+
+ ); +} diff --git a/src/views/neblogistic/ReportDetail.jsx b/src/views/neblogistic/ReportDetail.jsx new file mode 100644 index 0000000..6788c4f --- /dev/null +++ b/src/views/neblogistic/ReportDetail.jsx @@ -0,0 +1,308 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router'; +import { Button, Card, Col, Container, Form, Row, Spinner } from 'react-bootstrap'; +import { laravelGet, laravelPost, isLaravelSuccess } from '@/api/laravel'; +import PageBreadcrumb from '@/components/PageBreadcrumb'; +import { SPECIALITIES, STATUSES, displayValue, letterUrl } from './helpers'; + +function Field({ label, children }) { + return ( + + {label} + {children} + + ); +} + +function ReadRow({ label, value }) { + return ( +

+ {label}: {displayValue(value)} +

+ ); +} + +export default function NebReportDetail() { + const [params] = useSearchParams(); + const navigate = useNavigate(); + const id = params.get('id'); + const mode = params.get('requesttype') === 'edit' ? 'edit' : 'view'; + const filterQuery = useMemo(() => ({ + state: params.get('state') || '', + from_date: params.get('from_date') || '', + to_date: params.get('to_date') || '', + status: params.get('status') || '', + }), [params]); + + const [item, setItem] = useState(null); + const [doctors, setDoctors] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + const [form, setForm] = useState({}); + const [status, setStatus] = useState(''); + + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + setError(''); + const response = await laravelGet('neblogistic/show', { id }); + if (cancelled) return; + if (!isLaravelSuccess(response)) { + setError(response?.message || 'Unable to load record'); + setItem(null); + setLoading(false); + return; + } + const next = response.data?.item || {}; + setItem(next); + setDoctors(response.data?.doctors || []); + setStatus(next.status || ''); + setForm({ + emp_id: next.emp_id || '', + employee_name: next.emp_name || '', + employee_phone: next.emp_phone || '', + employee_email: next.emp_email || '', + abm_name: next.abm_name || '', + abm_email: next.abm_email || '', + rbm_name: next.rbm_name || '', + dr_name: next.doctor_name || '', + doctor_mobile_number: next.doctor_phone || '', + speciality: next.doctor_speciality || '', + msl_code: next.msl_code || '', + type_of_nebulizer: next.type_of_nebulizer || 'Robust', + more_than_5_nebulizer: next.more_than_5_nebulizer || 'No', + no_of_nebulizer: next.no_of_nebulizer || '', + value_of_the_parcel: next.value_of_parcel || '', + clinic_hospital_home_name: next.clinic_hospital_name || '', + building_name: next.address_building || '', + address_type: next.address_type || '', + address_line_1: next.address_line_1 || '', + address_line_2: next.address_line_2 || '', + state: next.address_state || '', + city: next.address_city || '', + pin_code: next.address_pin_code || '', + team: next.team || '', + depot_name: next.depot_name || '', + rejected_reason: next.rejected_reason || '', + order_no: next.order_no || '', + invoice_no: next.invoice_no || '', + courier_name: next.courier_name || '', + lr_no: next.lr_no || '', + lr_date: next.lr_date ? String(next.lr_date).slice(0, 10) : '', + }); + setLoading(false); + })(); + return () => { cancelled = true; }; + }, [id]); + + const goNeighbor = async (direction) => { + const response = await laravelGet('neblogistic/neighbor', { id, direction, ...filterQuery }); + if (!isLaravelSuccess(response)) { + setError(response?.message || 'No further entries available.'); + return; + } + const next = new URLSearchParams({ ...filterQuery, id: response.data.id, requesttype: mode }); + navigate(`/neblogisticreport/viewreportbyid?${next}`); + }; + + const save = async (payload) => { + setSaving(true); + setError(''); + setMessage(''); + const response = await laravelPost('neblogistic/update', { record_id: id, ...payload }); + setSaving(false); + if (!isLaravelSuccess(response)) { + setError(response?.message || 'Save failed'); + return; + } + setMessage(response.message || 'Data Saved'); + }; + + if (loading) { + return ; + } + if (!item) { + return
{error || 'Record not found'}
; + } + + const image = letterUrl(item.request_letter); + const canEdit = mode === 'edit'; + + return ( + + + {error ?
{error}
: null} + {message ?
{message}
: null} +
+
+ RequestID: {item.id} | Status:{' '} + {item.status} +
+
+ setStatus(e.target.value)}> + + {STATUSES.map((value) => )} + + + + + +
+
+ + + + + +

Employee Details

+ {canEdit ? ( + <> + setForm((p) => ({ ...p, emp_id: e.target.value }))} /> + setForm((p) => ({ ...p, employee_name: e.target.value }))} /> + setForm((p) => ({ ...p, employee_phone: e.target.value }))} /> + setForm((p) => ({ ...p, employee_email: e.target.value }))} /> + setForm((p) => ({ ...p, abm_name: e.target.value }))} /> + setForm((p) => ({ ...p, abm_email: e.target.value }))} /> + setForm((p) => ({ ...p, rbm_name: e.target.value }))} /> + + ) : ( + <> + + + + + + + + )} +

Doctor Details

+ {canEdit ? ( + <> + setForm((p) => ({ ...p, dr_name: e.target.value }))} /> + setForm((p) => ({ ...p, doctor_mobile_number: e.target.value }))} /> + + setForm((p) => ({ ...p, speciality: e.target.value }))}> + + {Object.entries(SPECIALITIES).map(([value, label]) => )} + + + setForm((p) => ({ ...p, msl_code: e.target.value }))} /> + + setForm((p) => ({ ...p, type_of_nebulizer: e.target.value }))}> + {['Robust', 'Mini', 'Others'].map((value) => )} + + + + setForm((p) => ({ ...p, more_than_5_nebulizer: e.target.value }))}> + + + + + setForm((p) => ({ ...p, no_of_nebulizer: e.target.value }))} /> + setForm((p) => ({ ...p, value_of_the_parcel: e.target.value }))} /> + + ) : ( + <> + + + + + + + + + + + )} +
+
+ + + {image ? ( + + +

Request Letter

+ Request Letter + Download existing +
+
+ ) : null} + + +

Drop Details

+ {canEdit ? ( + <> + setForm((p) => ({ ...p, clinic_hospital_home_name: e.target.value }))} /> + setForm((p) => ({ ...p, building_name: e.target.value }))} /> + setForm((p) => ({ ...p, address_type: e.target.value }))} /> + setForm((p) => ({ ...p, address_line_1: e.target.value }))} /> + setForm((p) => ({ ...p, address_line_2: e.target.value }))} /> + setForm((p) => ({ ...p, state: e.target.value }))} /> + setForm((p) => ({ ...p, city: e.target.value }))} /> + setForm((p) => ({ ...p, pin_code: e.target.value }))} /> + setForm((p) => ({ ...p, depot_name: e.target.value }))} /> + setForm((p) => ({ ...p, team: e.target.value }))} /> + + + ) : ( + <> + + + + + + + + + + )} +
+
+ {['Dispatched', 'Delivered'].includes(item.status) || status === 'Dispatched' ? ( + + +

Dispatch / Delivery Details

+ {canEdit ? ( + <> + setForm((p) => ({ ...p, order_no: e.target.value }))} /> + setForm((p) => ({ ...p, invoice_no: e.target.value }))} /> + setForm((p) => ({ ...p, courier_name: e.target.value }))} /> + setForm((p) => ({ ...p, lr_no: e.target.value }))} /> + setForm((p) => ({ ...p, lr_date: e.target.value }))} /> + + ) : ( + <> + + + + + + )} +
+
+ ) : null} + {status === 'Rejected' && canEdit ? ( + + + setForm((p) => ({ ...p, rejected_reason: e.target.value }))} /> + + + ) : null} + +
+ {doctors.length ? ( + + +

Other requests with same MSL

+ {doctors.map((row) => ( +
{row.id} — {row.emp_name} — {row.status}
+ ))} +
+
+ ) : null} +
+ ); +} diff --git a/src/views/neblogistic/ReportList.jsx b/src/views/neblogistic/ReportList.jsx new file mode 100644 index 0000000..86618b9 --- /dev/null +++ b/src/views/neblogistic/ReportList.jsx @@ -0,0 +1,209 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router'; +import { Button, Card, Col, Container, Form, Row, Spinner, Table } from 'react-bootstrap'; +import { TbEye, TbPencil } from 'react-icons/tb'; +import { laravelGet, laravelUrl, isLaravelSuccess } from '@/api/laravel'; +import { getAccessToken } from '@/auth/storage'; +import PageBreadcrumb from '@/components/PageBreadcrumb'; +import { formatReportDate, yesterdayInput } from './helpers'; + +export default function NebReportList() { + const [filters, setFilters] = useState({ depot: [], status: [], isDepotManager: false, isSuperDepotManager: false }); + const [query, setQuery] = useState({ state: '', status: '', from_date: yesterdayInput(), to_date: yesterdayInput(), search: '' }); + const [applied, setApplied] = useState({ state: '', status: '', from_date: yesterdayInput(), to_date: yesterdayInput(), search: '' }); + const [items, setItems] = useState([]); + const [count, setCount] = useState(0); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(200); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + laravelGet('neblogistic/filters').then((response) => { + if (isLaravelSuccess(response)) setFilters(response.data || {}); + }); + }, []); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + const response = await laravelGet('neblogistic/list', { + ...applied, + start: page * pageSize, + length: pageSize, + }); + if (!isLaravelSuccess(response)) { + setError(response?.message || 'Unable to load Neb Logistics reports'); + setItems([]); + setCount(0); + setLoading(false); + return; + } + setItems(response.data?.items || []); + setCount(response.data?.count || 0); + setLoading(false); + }, [applied, page, pageSize]); + + useEffect(() => { + load(); + }, [load]); + + const exportHref = useMemo(() => { + const params = new URLSearchParams({ + state: applied.state || '', + status: applied.status || '', + from_date: applied.from_date || '', + to_date: applied.to_date || '', + access_token: getAccessToken() || '', + }); + return `${laravelUrl('neblogistic/export')}?${params.toString()}`; + }, [applied]); + + const hideDepot = filters.isDepotManager; + const statusOptions = filters.isDepotManager || filters.isSuperDepotManager + ? (filters.status || []).filter((status) => status === 'Approved' || status === 'Dispatched') + : filters.status || []; + + return ( + + +
+ + + {!filters.isDepotManager && !filters.isSuperDepotManager ? ( + + ) : null} +
+ + + + {!hideDepot ? ( + + Depot + setQuery((prev) => ({ ...prev, state: e.target.value }))}> + + {(filters.depot || []).map((depot) => ( + + ))} + + + ) : null} + + Status + setQuery((prev) => ({ ...prev, status: e.target.value }))}> + {!filters.isDepotManager && !filters.isSuperDepotManager ? : null} + {statusOptions.map((status) => ( + + ))} + + + + From + setQuery((prev) => ({ ...prev, from_date: e.target.value }))} /> + + + To + setQuery((prev) => ({ ...prev, to_date: e.target.value }))} /> + + + + + + + + {error ?
{error}
: null} + + +
+ setQuery((prev) => ({ ...prev, search: e.target.value }))} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setPage(0); + setApplied((prev) => ({ ...prev, search: query.search })); + } + }} + /> +
{count} records
+
+ {loading ? ( +
+ ) : ( + + + + + + + + + + + + + + + + + {items.length ? items.map((row) => { + const params = new URLSearchParams({ + id: row.id, + state: applied.state || '', + from_date: applied.from_date || '', + to_date: applied.to_date || '', + status: applied.status || '', + }); + return ( + + + + + + + + + + + + + ); + }) : ( + + )} + +
IdEmployee NumberEmployee NameTeamDepot NameDoctor NameMSL CodeStatusRequest DateActions
{row.id}{row.emp_id}{row.emp_name}{row.team}{row.depot_name}{row.doctor_name}{row.msl_code}{row.status}{formatReportDate(row.created_at)} + {!filters.isDepotManager && !filters.isSuperDepotManager ? ( + + ) : null} + +
No records found.
+ )} +
+ { setPage(0); setPageSize(Number(e.target.value)); }}> + {[200, 350, 500, 750, 900].map((size) => )} + +
+ + +
+
+
+
+
+ ); +} diff --git a/src/views/neblogistic/helpers.js b/src/views/neblogistic/helpers.js new file mode 100644 index 0000000..07f4575 --- /dev/null +++ b/src/views/neblogistic/helpers.js @@ -0,0 +1,79 @@ +export const DEPOT_MANAGER = '1733912682-7c353bf8-c309-49b3-93e7-depotmanager'; +export const SUPER_DEPOT_MANAGER = '1733912682-7c353bf8-c309-49b3-93e7-superdepotmanager'; + +export const STATES = [ + 'Andhra Pradesh', + 'Andaman and Nicobar Islands', + 'Arunachal Pradesh', + 'Assam', + 'Bihar', + 'Chandigarh', + 'Chhattisgarh', + 'Dadar and Nagar Haveli', + 'Daman and Diu', + 'Delhi', + 'Lakshadweep', + 'Puducherry', + 'Goa', + 'Gujarat', + 'Haryana', + 'Himachal Pradesh', + 'Jammu and Kashmir', + 'Jharkhand', + 'Karnataka', + 'Kerala', + 'Madhya Pradesh', + 'Maharashtra', + 'Manipur', + 'Meghalaya', + 'Mizoram', + 'Nagaland', + 'Odisha', + 'Punjab', + 'Rajasthan', + 'Sikkim', + 'Tamil Nadu', + 'Telangana', + 'Tripura', + 'Uttar Pradesh', + 'Uttarakhand', + 'West Bengal', +]; + +export const SPECIALITIES = { + chest: 'Chest', + paedia: 'Paedia', + consulting_physician: 'Consulting Physician', + gp_mbbs: 'GP - MBBS', + gp_non_mbbs: 'GP - Non MBBS', + intensivist: 'Intensivist', + others: 'Others', +}; + +export const STATUSES = ['Requested', 'Approved', 'Rejected', 'Dispatched', 'Returned', 'Delivered']; + +export function yesterdayInput() { + const date = new Date(); + date.setDate(date.getDate() - 1); + return date.toISOString().slice(0, 10); +} + +export function formatReportDate(value) { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + return `${day}-${month}-${date.getFullYear()}`; +} + +export function letterUrl(path) { + if (!path) return ''; + return String(path).startsWith('http') ? path : `https://did94hhhcgq1d.cloudfront.net${path}`; +} + +export function displayValue(value) { + if (value == null || value === '') return ''; + if (Array.isArray(value)) return value.join(', '); + return String(value); +} diff --git a/src/views/organisation/HierarchyTree.jsx b/src/views/organisation/HierarchyTree.jsx index d0bf057..b60d8fa 100644 --- a/src/views/organisation/HierarchyTree.jsx +++ b/src/views/organisation/HierarchyTree.jsx @@ -71,7 +71,14 @@ const emptyChild = { channels: '', }; -const CHILDREN_PARAMS = { children: 1, source: 'elastic' }; +function hierarchyItems(data) { + if (Array.isArray(data?.items)) return data.items; + if (Array.isArray(data?.hierarchys)) return data.hierarchys; + if (Array.isArray(data)) return data; + return []; +} + +const CHILDREN_PARAMS = { children: 1 }; function createExpandStore() { let state = {}; @@ -249,10 +256,15 @@ export default function HierarchyTree() { if (!parentId || isCrestroad()) return []; setLoadingIds((prev) => ({ ...prev, [parentId]: true })); try { - const response = await signedGet('ums', 'hierarchy/get_all', { - ...CHILDREN_PARAMS, - parent_id: parentId, - }); + const response = await signedGet( + 'ums', + 'hierarchy/get_all', + { + ...CHILDREN_PARAMS, + parent_id: parentId, + }, + { timeout: 120000 }, + ); if (response?.statusCode === 401 || response?.statusCode === 403) { if (!silent) setError('Unauthorised'); return []; @@ -261,7 +273,7 @@ export default function HierarchyTree() { if (!silent) setError(response?.message || 'Unable to load hierarchy'); return []; } - const items = response.data?.items || []; + const items = hierarchyItems(response.data); mergeChildren(parentId, items); return items; } catch (err) { @@ -285,7 +297,7 @@ export default function HierarchyTree() { const crestroad = isCrestroad(); const listRes = crestroad ? await signedGet('catalog', 'activity/hierarchy_list') - : await signedGet('ums', 'hierarchy/get_all', CHILDREN_PARAMS); + : await signedGet('ums', 'hierarchy/get_all', CHILDREN_PARAMS, { timeout: 120000 }); if (listRes?.statusCode === 401 || listRes?.statusCode === 403) { setError('Unauthorised'); return; @@ -295,7 +307,7 @@ export default function HierarchyTree() { return; } const me = listRes.data?.me; - const items = listRes.data?.items || []; + const items = hierarchyItems(listRes.data); if (crestroad) { const ingested = ingestFullTree(items, me); setNodesById(ingested.nodes); @@ -371,10 +383,10 @@ export default function HierarchyTree() { expandStore.collapse(id); return; } + expandStore.expand(id); if (!isCrestroad() && childrenByIdRef.current[id] === undefined) { await fetchChildren(id); } - expandStore.expand(id); }, [expandStore, fetchChildren], ); diff --git a/src/views/rcpareport/FormWise.jsx b/src/views/rcpareport/FormWise.jsx new file mode 100644 index 0000000..d802d6e --- /dev/null +++ b/src/views/rcpareport/FormWise.jsx @@ -0,0 +1,185 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Button, Card, Container, Form } from 'react-bootstrap'; +import { signedGet, isSuccess } from '@/api/client'; +import PageBreadcrumb from '@/components/PageBreadcrumb'; +import ReportGrid from './ReportGrid'; +import { asList, brandField, extractFormRows, FORM_REPORT_TIMEOUT, REPORT_TIMEOUT } from './helpers'; + +const BASE_COLUMNS = [ + { key: 'user_name', title: 'User Name', width: 150, pinned: 'left' }, + { key: 'employee_code', title: 'Employee Code', width: 120 }, + { key: 'record_date', title: 'Submition Date', width: 120 }, + { key: 'contact_id', title: 'Doctor Name', width: 120 }, + { key: 'msl_code', title: 'MSL Code', width: 120 }, + { key: 'speciality', title: 'Speciality', width: 150 }, + { key: 'chemist_name', title: 'Chemist Name', width: 150 }, + { key: 'type_of_chemist', title: 'Chemist Type', width: 150 }, + { key: 'doctors_attached_to_chemist', title: 'Doctors Attached to Chemist', width: 180 }, +]; + +function flattenFormRows(source) { + const brandMap = {}; + const rows = (source || []).map((row) => { + const flat = { ...row }; + asList(row.brands).forEach((brand) => { + const brandName = brand?.brand_name; + if (!brandName) return; + if (!brandMap[brandName]) brandMap[brandName] = new Set(); + flat[brandField(brandName, 'cipla_qty')] = brand.cipla_qty; + flat[brandField(brandName, 'cipla_val')] = brand.cipla_value; + asList(brand.competitors).forEach((competitor) => { + const competitorName = competitor?.competitor_name; + if (!competitorName) return; + brandMap[brandName].add(competitorName); + flat[brandField(brandName, `${competitorName}::qty`)] = competitor.competitors_qty; + flat[brandField(brandName, `${competitorName}::val`)] = competitor.competitors_value; + }); + }); + return flat; + }); + return { rows, brandMap }; +} + +export default function RcpaFormWise() { + const gridRef = useRef(null); + const [forms, setForms] = useState([]); + const [formId, setFormId] = useState(''); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + const [rows, setRows] = useState([]); + const [brandMap, setBrandMap] = useState({}); + const [loadingForms, setLoadingForms] = useState(true); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + const loadForms = async () => { + setLoadingForms(true); + setError(''); + try { + const response = await signedGet('form', 'formentity/dropdown', {}, { timeout: REPORT_TIMEOUT }); + if (!isSuccess(response)) { + setError(response?.message || 'Unable to load forms'); + setForms([]); + return; + } + const items = (response.data?.items || []).filter((item) => /rcpa/i.test(item.title || '')); + setForms(items); + } catch (err) { + setError(err.message || 'Unable to load forms'); + setForms([]); + } finally { + setLoadingForms(false); + } + }; + loadForms(); + }, []); + + const fetchReport = async () => { + setLoading(true); + setError(''); + try { + const params = {}; + if (formId) params.form_id = formId; + if (dateFrom && dateTo) params.date_range = `${dateFrom}_${dateTo}`; + const response = await signedGet('form', 'formentity/rcpa_transpose_report', params, { + timeout: FORM_REPORT_TIMEOUT, + }); + if (!isSuccess(response)) { + setError(response?.message || 'Unable to load form wise report'); + setRows([]); + setBrandMap({}); + return; + } + const flattened = flattenFormRows(extractFormRows(response.data)); + setRows(flattened.rows); + setBrandMap(flattened.brandMap); + } catch (err) { + setError(err.message || 'Unable to load form wise report'); + setRows([]); + setBrandMap({}); + } finally { + setLoading(false); + } + }; + + const columns = useMemo(() => { + const brandColumns = Object.keys(brandMap) + .sort() + .map((brandName) => { + const competitors = Array.from(brandMap[brandName] || []).sort(); + return { + title: brandName, + children: [ + { key: brandField(brandName, 'cipla_qty'), title: 'Cipla Qty', width: 100, type: 'numericColumn' }, + { key: brandField(brandName, 'cipla_val'), title: 'Cipla Value', width: 100, type: 'numericColumn' }, + ...competitors.map((competitorName) => ({ + title: competitorName, + children: [ + { key: brandField(brandName, `${competitorName}::qty`), title: 'Qty', width: 100, type: 'numericColumn' }, + { key: brandField(brandName, `${competitorName}::val`), title: 'Value', width: 100, type: 'numericColumn' }, + ], + })), + ], + }; + }); + return [...BASE_COLUMNS, ...brandColumns]; + }, [brandMap]); + + return ( + + + {error ?
{error}
: null} + + +
{ + event.preventDefault(); + fetchReport(); + }} + > +
+ Form + setFormId(event.target.value)} + disabled={loadingForms} + style={{ minWidth: 220 }} + > + + {forms.map((form) => ( + + ))} + +
+
+ From + setDateFrom(event.target.value)} /> +
+
+ To + setDateTo(event.target.value)} /> +
+ +
+ +
+
+ +
+ ); +} diff --git a/src/views/rcpareport/MonthWise.jsx b/src/views/rcpareport/MonthWise.jsx new file mode 100644 index 0000000..a40a539 --- /dev/null +++ b/src/views/rcpareport/MonthWise.jsx @@ -0,0 +1,128 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Button, Card, Container, Form } from 'react-bootstrap'; +import { signedGet, isSuccess } from '@/api/client'; +import PageBreadcrumb from '@/components/PageBreadcrumb'; +import ReportGrid from './ReportGrid'; +import { + asList, + DEFAULT_SKU, + extractAgg, + extractSkus, + monthFieldKey, + monthLabel, + REPORT_TIMEOUT, +} from './helpers'; + +export default function RcpaMonthWise() { + const gridRef = useRef(null); + const [skus, setSkus] = useState([]); + const [skuId, setSkuId] = useState(''); + const [months, setMonths] = useState([]); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + const loadSkus = async () => { + try { + const response = await signedGet('rcpa', 'sku/list', {}, { timeout: REPORT_TIMEOUT }); + if (!isSuccess(response)) { + setError(response?.message || 'Unable to load SKUs'); + setSkus([]); + setSkuId(DEFAULT_SKU); + return; + } + const list = extractSkus(response.data); + setSkus(list); + setSkuId(list[0]?.id || DEFAULT_SKU); + } catch (err) { + setError(err.message || 'Unable to load SKUs'); + setSkuId(DEFAULT_SKU); + } + }; + loadSkus(); + }, []); + + useEffect(() => { + if (!skuId) return; + const load = async () => { + setLoading(true); + setError(''); + try { + const response = await signedGet('rcpa', 'sku/monthwise', { sku: skuId }, { timeout: REPORT_TIMEOUT }); + if (!isSuccess(response)) { + setError(response?.message || 'Unable to load month wise report'); + setMonths([]); + setRows([]); + return; + } + setMonths(asList(response.data?.months)); + setRows(extractAgg(response.data)); + } catch (err) { + setError(err.message || 'Unable to load month wise report'); + setMonths([]); + setRows([]); + } finally { + setLoading(false); + } + }; + load(); + }, [skuId]); + + const columns = useMemo( + () => [ + { key: 'sku', title: 'Brand' }, + { key: 'competitor', title: 'Competitor' }, + ...months.map((month) => { + const suffix = monthFieldKey(month); + return { + title: monthLabel(month), + children: [ + { key: `quantity_${suffix}`, title: 'Quantity' }, + { key: `business_${suffix}`, title: 'Business' }, + ], + }; + }), + ], + [months], + ); + + return ( + + + {error ?
{error}
: null} + + +
+ SKU + setSkuId(event.target.value)} + style={{ maxWidth: 280 }} + disabled={!skus.length && !skuId} + > + {!skus.length ? : null} + {skus.map((sku) => ( + + ))} + +
+ +
+
+ +
+ ); +} diff --git a/src/views/rcpareport/ReportGrid.jsx b/src/views/rcpareport/ReportGrid.jsx new file mode 100644 index 0000000..038f43d --- /dev/null +++ b/src/views/rcpareport/ReportGrid.jsx @@ -0,0 +1,73 @@ +import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'; +import { AgGridReact } from 'ag-grid-react'; +import { Card, Spinner } from 'react-bootstrap'; +import { toColumnDefs } from './helpers'; +import 'ag-grid-community/styles/ag-grid.css'; +import 'ag-grid-community/styles/ag-theme-alpine.css'; + +const DEFAULT_COL_DEF = { + resizable: true, + sortable: true, + filter: 'agTextColumnFilter', + floatingFilter: true, + width: 170, +}; + +const ReportGrid = forwardRef(function ReportGrid( + { + columns, + rows, + loading, + filename = 'rcpa-report.csv', + emptyDescription = 'No report data to show', + highlightBase = false, + }, + ref, +) { + const gridRef = useRef(null); + const columnDefs = useMemo(() => toColumnDefs(columns), [columns]); + const overlayNoRowsTemplate = `${emptyDescription}`; + const defaultColDef = useMemo(() => { + if (!highlightBase) return DEFAULT_COL_DEF; + return { + ...DEFAULT_COL_DEF, + cellClassRules: { + 'header-cell': (params) => params.data?.base === true, + }, + }; + }, [highlightBase]); + + useImperativeHandle(ref, () => ({ + exportCsv: () => { + gridRef.current?.api?.exportDataAsCsv({ fileName: filename }); + }, + }), [filename]); + + return ( + + + {loading ? ( +
+ +
+ ) : null} +
+ +
+
+
+ ); +}); + +export default ReportGrid; diff --git a/src/views/rcpareport/TmWise.jsx b/src/views/rcpareport/TmWise.jsx new file mode 100644 index 0000000..d7c137e --- /dev/null +++ b/src/views/rcpareport/TmWise.jsx @@ -0,0 +1,107 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Button, Card, Container, Form } from 'react-bootstrap'; +import { signedGet, isSuccess } from '@/api/client'; +import PageBreadcrumb from '@/components/PageBreadcrumb'; +import ReportGrid from './ReportGrid'; +import { extractAgg, extractSkus, rcpaMonthsFrom, REPORT_TIMEOUT, skuCompetitors } from './helpers'; + +const IDENTITY_COLUMNS = [ + { key: 'user_name', title: 'TM Name' }, + { key: 'employee_code', title: 'Employee Code' }, + { key: 'email', title: 'Email' }, + { key: 'no_of_contacts', title: 'No of Chemist' }, +]; + +export default function RcpaTmWise() { + const months = useMemo(() => rcpaMonthsFrom(), []); + const gridRef = useRef(null); + const [month, setMonth] = useState(months[0] || ''); + const [skus, setSkus] = useState([]); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + const load = async () => { + setLoading(true); + setError(''); + try { + const [listRes, reportRes] = await Promise.all([ + signedGet('rcpa', 'sku/list', {}, { timeout: REPORT_TIMEOUT }), + signedGet('rcpa', 'sku/tmwise', month ? { month } : {}, { timeout: REPORT_TIMEOUT }), + ]); + if (!isSuccess(listRes)) { + setError(listRes?.message || 'Unable to load SKUs'); + setSkus([]); + } else { + setSkus(extractSkus(listRes.data)); + } + if (!isSuccess(reportRes)) { + setError((prev) => prev || reportRes?.message || 'Unable to load TM wise report'); + setRows([]); + } else { + setRows(extractAgg(reportRes.data)); + } + } catch (err) { + setError(err.message || 'Unable to load TM wise report'); + setRows([]); + } finally { + setLoading(false); + } + }; + load(); + }, [month]); + + const columns = useMemo( + () => [ + ...IDENTITY_COLUMNS, + ...skus.map((sku) => ({ + title: sku.title || sku.id, + children: [ + { key: `quantity_${sku.id}`, title: sku.title || sku.id }, + ...skuCompetitors(sku).map((competitor) => ({ + key: `quantity_${sku.id}_${competitor.id}`, + title: competitor.title || competitor.id, + })), + ], + })), + ], + [skus], + ); + + return ( + + + {error ?
{error}
: null} + + +
+ Month + setMonth(event.target.value)} + style={{ maxWidth: 220 }} + > + {months.map((item) => ( + + ))} + +
+ +
+
+ +
+ ); +} diff --git a/src/views/rcpareport/helpers.js b/src/views/rcpareport/helpers.js new file mode 100644 index 0000000..557adae --- /dev/null +++ b/src/views/rcpareport/helpers.js @@ -0,0 +1,89 @@ +export const REPORT_TIMEOUT = 180000; +export const FORM_REPORT_TIMEOUT = 300000; +export const MONTH_START = 'September 2024'; +export const DEFAULT_SKU = 'sku1'; + +export function asList(value) { + if (Array.isArray(value)) return value; + if (value && typeof value === 'object') return Object.values(value); + return []; +} + +export function extractSkus(data) { + if (Array.isArray(data?.skus)) return data.skus; + if (Array.isArray(data?.items)) return data.items; + return asList(data).filter((item) => item && (item.id || item.title)); +} + +export function extractAgg(data) { + if (Array.isArray(data?.agg)) return data.agg; + if (Array.isArray(data?.chemists)) return data.chemists; + if (Array.isArray(data?.items)) return data.items; + if (Array.isArray(data)) return data; + return []; +} + +export function extractFormRows(data) { + if (Array.isArray(data?.Result)) return data.Result; + if (Array.isArray(data?.result)) return data.result; + if (Array.isArray(data?.items)) return data.items; + if (Array.isArray(data?.response)) return data.response; + if (Array.isArray(data)) return data; + return []; +} + +export function rcpaMonthsFrom(start = MONTH_START) { + const months = []; + const now = new Date(); + const cursor = new Date(start); + if (Number.isNaN(cursor.getTime())) return months; + cursor.setDate(1); + cursor.setHours(0, 0, 0, 0); + while (cursor <= now) { + months.push(cursor.toLocaleString('en-US', { month: 'long', year: 'numeric' })); + cursor.setMonth(cursor.getMonth() + 1); + } + return months; +} + +export function monthFieldKey(month) { + if (month == null) return ''; + if (typeof month === 'object') { + return String(month.timestamp ?? month.ts ?? month.month_text ?? ''); + } + return String(month); +} + +export function monthLabel(month) { + if (month == null) return ''; + if (typeof month === 'object') return String(month.month_text || month.label || month.timestamp || ''); + return String(month); +} + +export function toColumnDefs(columns) { + return (columns || []).map((column) => { + const def = { + headerName: column.title || column.headerName || '', + width: column.width || 170, + }; + if (column.pinned) def.pinned = column.pinned; + if (column.type) def.type = column.type; + if (column.children?.length) { + def.children = toColumnDefs(column.children); + return def; + } + if (column.key || column.field) def.field = column.key || column.field; + if (typeof column.valueGetter === 'function') { + def.valueGetter = (params) => column.valueGetter(params.data); + } + return def; + }); +} + +export function skuCompetitors(sku) { + return asList(sku?.competitors || sku?.competitor); +} + +export function brandField(brandName, suffix) { + return `${brandName}::${suffix}`; +}