## 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)
This commit is contained in:
Amey Bhagwat 2026-10-13 11:49:50 +05:30
parent 16c0ef827c
commit 7355130e02
39 changed files with 2721 additions and 84 deletions

View File

@ -84,16 +84,25 @@ async function request(method, service, path, data = {}, { signed = true, timeou
const payload = error.response?.data; const payload = error.response?.data;
const apiStatus = payload?.statusCode; const apiStatus = payload?.statusCode;
if (signed && (status === 401 || apiStatus === 401)) { if (signed && (status === 401 || apiStatus === 401)) {
let token;
try {
token = await ensureRefresh();
} catch {
clearAndLogout();
return payload || { statusCode: 401, status: 'error', message: 'Unauthorised' };
}
try { try {
const token = await ensureRefresh();
const retry = await axios({ const retry = await axios({
...config, ...config,
headers: { ...headers, Authorization: token }, headers: { ...headers, Authorization: token },
}); });
return retry.data; return retry.data;
} catch { } catch (retryError) {
clearAndLogout(); return (
return payload || { statusCode: 401, status: 'error', message: 'Unauthorised' }; retryError.response?.data ||
payload ||
{ statusCode: 401, status: 'error', message: 'Unauthorised' }
);
} }
} }
if (payload) return payload; if (payload) return payload;

View File

@ -25,7 +25,7 @@ export const LOCAL_SERVICES = {
export const isLocalApi = () => import.meta.env.VITE_API_MODE === 'local'; export const isLocalApi = () => import.meta.env.VITE_API_MODE === 'local';
export function resolveUrl(service, path) { export function resolveUrl(service, path) {
if (isLocalApi()) { if (isLocalApi() || import.meta.env.DEV) {
return `/api/${service}/${path}`; return `/api/${service}/${path}`;
} }
// Do not call Lambda Function URLs from the browser. Their CORS config // Do not call Lambda Function URLs from the browser. Their CORS config

View File

@ -1,6 +1,16 @@
import { signedGet, isSuccess } from '@/api/client'; import { signedGet, isSuccess } from '@/api/client';
import { loadRoleMap } from '@/helpers/roles'; 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) { export async function dropdownMap(service, module, labelFn, keyFn) {
if (service === 'ums' && module === 'roles' && !keyFn) { if (service === 'ums' && module === 'roles' && !keyFn) {
return loadRoleMap(); return loadRoleMap();

59
src/api/laravel.js Normal file
View File

@ -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');
}

View File

@ -95,3 +95,5 @@
@import "plugins/react-slider"; @import "plugins/react-slider";
@import "plugins/multiselect"; @import "plugins/multiselect";
@import "pages/dashboard"; @import "pages/dashboard";
@import "pages/rcpa";
@import "pages/gennovadc";

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -22,8 +22,13 @@ import TemplatePositionsField from '@/components/form/TemplatePositionsField';
import S3FileField from '@/components/S3FileField'; import S3FileField from '@/components/S3FileField';
import { parseCombinedMetrics } from '@/helpers/metric'; import { parseCombinedMetrics } from '@/helpers/metric';
function toOptions(values = {}) { function toOptions(values = {}, pinFirst) {
return Object.entries(values).map(([value, label]) => ({ value, label })); 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) { function defaultValue(field, item) {
@ -340,7 +345,7 @@ function FieldControl({ field, control, register, watch, moduleName, onUploading
return <BigMultiSelectField field={field} control={control} />; return <BigMultiSelectField field={field} control={control} />;
} }
if (field.type === 'multiselect') { if (field.type === 'multiselect') {
const options = toOptions(field.values); const options = toOptions(field.values, field.exclusiveValue || field.pinFirst);
return ( return (
<Controller <Controller
name={field.key} name={field.key}
@ -496,7 +501,9 @@ export default function EntityForm({ module: config }) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const isAddNew = /\/addnew\/?$/.test(location.pathname); const isAddNew =
/\/addnew\/?$/.test(location.pathname) ||
Boolean(config.addPath && location.pathname.replace(/\/$/, '') === String(config.addPath).replace(/\/$/, ''));
const id = isAddNew ? null : searchParams.get('id'); const id = isAddNew ? null : searchParams.get('id');
const singleton = Boolean(config.singleton); const singleton = Boolean(config.singleton);
const mode = singleton || id ? 'edit' : 'new'; const mode = singleton || id ? 'edit' : 'new';

View File

@ -312,7 +312,10 @@ export default function EntityList({ module: config }) {
if (config.listFields.some(isRoleListField)) { if (config.listFields.some(isRoleListField)) {
await loadRoleMap(); await loadRoleMap();
} }
const rows = [spec.headers, ...rowsSource.map((item) => 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`); downloadCsv(rows, spec.filename || `${config.module}.csv`);
setMessage('Export downloaded'); setMessage('Export downloaded');
} catch (err) { } catch (err) {

View File

@ -84,7 +84,7 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
icon: LuUsers, icon: LuUsers,
children: [ children: [
child('contact-list', 'Contacts', '/contacts', 'contact_get_all'), 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-attrs', 'Attributes', '/contactattributes', 'contactattribute_get_all'),
child('contact-lists', 'Contact Lists', '/contactlists', 'contact_list_get_all'), child('contact-lists', 'Contact Lists', '/contactlists', 'contact_list_get_all'),
].filter((item) => !item.permission || permissions.includes(item.permission)), ].filter((item) => !item.permission || permissions.includes(item.permission)),

View File

@ -12,8 +12,9 @@ const contactexports = {
module: 'contact_exports', module: 'contact_exports',
title: 'Contact Exports', title: 'Contact Exports',
subtitle: 'Contact Management', subtitle: 'Contact Management',
baseUrl: '/contactexport', baseUrl: '/contact',
listPath: '/contactexports', listPath: '/contact/export',
addPath: '/contact/exportcreate',
skipEdit: true, skipEdit: true,
deleteMethod: 'post', deleteMethod: 'post',
afterSaveMessage: 'Export request created. Refresh the list in a few moments to download the file when ready.', 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' }, { key: 'createdAt', title: 'Created Date', type: 'datetime' },
], ],
listButtons: [ listButtons: [
{ title: 'Add New', to: '/contactexport/addnew' }, { title: 'Create Export', to: '/contact/exportcreate' },
{ title: 'Refresh', action: 'reload' }, { title: 'Refresh', action: 'reload' },
], ],
mapItems: (items) => mapItems: (items) =>
@ -45,6 +46,8 @@ const contactexports = {
(item) => `${item.name} (${item.code})`, (item) => `${item.name} (${item.code})`,
(item) => item.code, (item) => item.code,
); );
const positionValues = { ...positions };
delete positionValues.all;
return [ return [
{ {
key: 'position_code', key: 'position_code',
@ -53,14 +56,14 @@ const contactexports = {
required: true, required: true,
exclusiveValue: 'all', exclusiveValue: 'all',
fullwidth: true, fullwidth: true,
values: { all: 'All positions', ...positions }, values: { all: 'All Position Code', ...positionValues },
}, },
{ {
key: 'export_status', key: 'export_status',
title: 'Contact Status', title: 'Contact Status',
type: 'select', type: 'select',
required: true, required: true,
values: { active: 'Active', disabled: 'Disabled' }, values: { active: 'Active', disabled: 'Disabled', deleted: 'Deleted' },
}, },
]; ];
}, },

View File

@ -31,7 +31,7 @@ const contacts = {
listButtons: [ listButtons: [
{ title: 'Add New', to: '/contact/addnew' }, { title: 'Add New', to: '/contact/addnew' },
{ title: 'Import Contacts', to: '/contact/import' }, { title: 'Import Contacts', to: '/contact/import' },
{ title: 'Export', to: '/contactexports' }, { title: 'Export', to: '/contact/export' },
], ],
formFields: async () => { formFields: async () => {
const [attributesRes, positions] = await Promise.all([ const [attributesRes, positions] = await Promise.all([

View File

@ -1,5 +1,5 @@
import { signedGet, signedPost, isSuccess } from '@/api/client'; import { signedGet, signedPost, isSuccess } from '@/api/client';
import { dropdownMap } from '@/api/dropdowns'; import { dropdownMap, fetchUserAttributes } from '@/api/dropdowns';
import { splitHash } from '@/helpers/metric'; import { splitHash } from '@/helpers/metric';
const STATUS = { active: 'Active', disabled: 'Disabled' }; const STATUS = { active: 'Active', disabled: 'Disabled' };
@ -56,11 +56,11 @@ const formentitys = {
{ key: 'status', title: 'Status', type: 'select' }, { key: 'status', title: 'Status', type: 'select' },
], ],
formFields: async () => { formFields: async () => {
const [usersRes, roles, contactRes, userAttrRes, workflowRes] = await Promise.all([ const [usersRes, roles, contactRes, userAttrItems, workflowRes] = await Promise.all([
signedGet('ums', 'users/dropdown'), signedGet('ums', 'users/dropdown'),
dropdownMap('ums', 'roles', (item) => item.name), dropdownMap('ums', 'roles', (item) => item.name),
signedGet('contact', 'contactattribute/get_all'), signedGet('contact', 'contactattribute/get_all'),
signedGet('ums', 'userattribute/get_all'), fetchUserAttributes(),
signedGet('workflow', 'workflow_definition/get_all'), signedGet('workflow', 'workflow_definition/get_all'),
]); ]);
const approvers = { const approvers = {
@ -83,7 +83,6 @@ const formentitys = {
if (attr?.code) contactAttributes[attr.code] = attr.attribute || attr.code; if (attr?.code) contactAttributes[attr.code] = attr.attribute || attr.code;
}); });
const userAttributes = {}; const userAttributes = {};
const userAttrItems = isSuccess(userAttrRes) ? userAttrRes.data?.items || [] : [];
userAttrItems.forEach((attr) => { userAttrItems.forEach((attr) => {
if (!attr?.code || attr.status === 'disabled') return; if (!attr?.code || attr.status === 'disabled') return;
userAttributes[attr.code] = attr.attribute || attr.code; userAttributes[attr.code] = attr.attribute || attr.code;

View File

@ -1,5 +1,5 @@
import { signedGet, isSuccess } from '@/api/client'; import { signedGet, isSuccess } from '@/api/client';
import { dropdownMap } from '@/api/dropdowns'; import { dropdownMap, fetchUserAttributes } from '@/api/dropdowns';
import { import {
INTERVAL_VALUES, INTERVAL_VALUES,
metricOptions, metricOptions,
@ -93,11 +93,17 @@ const leaderboards = {
sort_order: item.sort_order ?? 0, sort_order: item.sort_order ?? 0,
})), })),
formFields: async () => { formFields: async () => {
const [metrics, roles, teams] = await Promise.all([ const [metrics, roles, teams, userAttrItems] = await Promise.all([
metricOptions(), metricOptions(),
dropdownMap('ums', 'roles', (item) => item.name), dropdownMap('ums', 'roles', (item) => item.name),
teamOptions(), teamOptions(),
fetchUserAttributes(),
]); ]);
const userAttributes = {};
userAttrItems.forEach((attr) => {
if (!attr?.code || attr.status === 'disabled') return;
userAttributes[attr.code] = attr.attribute || attr.code;
});
return [ return [
{ key: 'name', title: 'Name', type: 'text', required: true }, { key: 'name', title: 'Name', type: 'text', required: true },
{ key: 'sort_order', title: 'Sort Order', type: 'number', required: true }, { key: 'sort_order', title: 'Sort Order', type: 'number', required: true },
@ -151,6 +157,15 @@ const leaderboards = {
values: { all: 'All', loggedin: 'Logged In' }, values: { all: 'All', loggedin: 'Logged In' },
}, },
{ key: 'show_units_export', title: 'Show Units Export', type: 'select', values: YES_NO }, { 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_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: 'show_combined_metrics', title: 'Show Combined Metrics', type: 'select', values: YES_NO },
{ key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS }, { 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 = String(next.show_positions || 'no').toLowerCase();
next.show_positions_type = next.show_positions_type || 'all'; next.show_positions_type = next.show_positions_type || 'all';
next.show_units_export = next.show_units_export === 'no' ? 'no' : 'yes'; 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_normal_metrics = next.show_normal_metrics === 'no' ? 'no' : 'yes';
next.show_combined_metrics = next.show_combined_metrics === 'no' ? 'no' : 'yes'; next.show_combined_metrics = next.show_combined_metrics === 'no' ? 'no' : 'yes';
next.total_units_name = next.total_units_name || ''; next.total_units_name = next.total_units_name || '';
@ -207,6 +227,8 @@ const leaderboards = {
payload.intervals = asArray(payload.intervals); payload.intervals = asArray(payload.intervals);
payload.leaderboard_team_ids = leaderboardType === 'team' ? asArray(payload.leaderboard_team_ids) : []; payload.leaderboard_team_ids = leaderboardType === 'team' ? asArray(payload.leaderboard_team_ids) : [];
payload.show_units_export = payload.show_units_export === 'no' ? 'no' : 'yes'; 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_normal_metrics = payload.show_normal_metrics === 'no' ? 'no' : 'yes';
payload.show_combined_metrics = payload.show_combined_metrics === 'no' ? 'no' : 'yes'; payload.show_combined_metrics = payload.show_combined_metrics === 'no' ? 'no' : 'yes';
payload.total_units_name = payload.total_units_name || ''; payload.total_units_name = payload.total_units_name || '';

View File

@ -1,5 +1,4 @@
import { signedGet, isSuccess } from '@/api/client'; import { dropdownMap, fetchUserAttributes } from '@/api/dropdowns';
import { dropdownMap } from '@/api/dropdowns';
import { import {
CAMPAIGN_COMBINED_TYPES, CAMPAIGN_COMBINED_TYPES,
INTERVAL_VALUES, INTERVAL_VALUES,
@ -96,14 +95,13 @@ const metriccampaigns = {
teams_rank: item.teams_rank ? String(item.teams_rank).toLowerCase().slice(0, 2) : '', teams_rank: item.teams_rank ? String(item.teams_rank).toLowerCase().slice(0, 2) : '',
})), })),
formFields: async () => { formFields: async () => {
const [roles, metrics, attrsRes] = await Promise.all([ const [roles, metrics, attrItems] = await Promise.all([
dropdownMap('ums', 'roles', (item) => item.name), dropdownMap('ums', 'roles', (item) => item.name),
metricOptions(), metricOptions(),
signedGet('ums', 'userattribute/get_all'), fetchUserAttributes(),
]); ]);
const attributes = {}; const attributes = {};
const attributesMeta = {}; const attributesMeta = {};
const attrItems = isSuccess(attrsRes) ? attrsRes.data?.items || [] : [];
attrItems.forEach((attr) => { attrItems.forEach((attr) => {
if ((attr.status || 'active') === 'disabled') return; if ((attr.status || 'active') === 'disabled') return;
const code = attr.code || ''; const code = attr.code || '';
@ -178,6 +176,15 @@ const metriccampaigns = {
values: { all: 'All', loggedin: 'Logged In' }, values: { all: 'All', loggedin: 'Logged In' },
}, },
{ key: 'show_units_export', title: 'Show Units Export', type: 'select', values: YES_NO }, { 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_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: 'show_combined_metrics', title: 'Show Combined Metrics', type: 'select', values: YES_NO },
{ key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS }, { key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS },
@ -226,6 +233,11 @@ const metriccampaigns = {
next.manager_details = asArray(next.manager_details); next.manager_details = asArray(next.manager_details);
if (!next.manager_details.length) next.manager_details = ['l1manager']; if (!next.manager_details.length) next.manager_details = ['l1manager'];
next.show_units_export = next.show_units_export === 'no' ? 'no' : 'yes'; 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_normal_metrics = next.show_normal_metrics === 'no' ? 'no' : 'yes';
next.show_combined_metrics = next.show_combined_metrics === 'no' ? 'no' : 'yes'; next.show_combined_metrics = next.show_combined_metrics === 'no' ? 'no' : 'yes';
next.total_units_name = next.total_units_name || ''; next.total_units_name = next.total_units_name || '';
@ -277,6 +289,8 @@ const metriccampaigns = {
intervals: asArray(data.intervals), intervals: asArray(data.intervals),
manager_details: asArray(data.manager_details).length ? asArray(data.manager_details) : ['l1manager'], manager_details: asArray(data.manager_details).length ? asArray(data.manager_details) : ['l1manager'],
show_units_export: data.show_units_export === 'no' ? 'no' : 'yes', 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_normal_metrics: data.show_normal_metrics === 'no' ? 'no' : 'yes',
show_combined_metrics: data.show_combined_metrics === 'no' ? 'no' : 'yes', show_combined_metrics: data.show_combined_metrics === 'no' ? 'no' : 'yes',
total_units_name: data.total_units_name || '', total_units_name: data.total_units_name || '',

View File

@ -1,5 +1,5 @@
import { signedGet, isSuccess } from '@/api/client'; import { signedGet, isSuccess } from '@/api/client';
import { dropdownMap } from '@/api/dropdowns'; import { dropdownMap, fetchUserAttributes } from '@/api/dropdowns';
import { import {
DEFAULT_INTERVAL_VALUES, DEFAULT_INTERVAL_VALUES,
INTERVAL_VALUES, INTERVAL_VALUES,
@ -48,11 +48,11 @@ const metricviews = {
sort_order: item.sort_order ?? item.widget_sort_order ?? 0, sort_order: item.sort_order ?? item.widget_sort_order ?? 0,
})), })),
formFields: async () => { formFields: async () => {
const [metrics, roles, contactRes, userAttrRes] = await Promise.all([ const [metrics, roles, contactRes, userAttrItems] = await Promise.all([
metricOptions(), metricOptions(),
dropdownMap('ums', 'roles', (item) => item.name), dropdownMap('ums', 'roles', (item) => item.name),
signedGet('contact', 'contactattribute/get_all'), signedGet('contact', 'contactattribute/get_all'),
signedGet('ums', 'userattribute/get_all'), fetchUserAttributes(),
]); ]);
const contactAttributes = {}; const contactAttributes = {};
const contactItems = isSuccess(contactRes) ? contactRes.data?.items || [] : []; const contactItems = isSuccess(contactRes) ? contactRes.data?.items || [] : [];
@ -60,7 +60,6 @@ const metricviews = {
if (attr?.code) contactAttributes[attr.code] = attr.attribute || attr.code; if (attr?.code) contactAttributes[attr.code] = attr.attribute || attr.code;
}); });
const userAttributes = {}; const userAttributes = {};
const userAttrItems = isSuccess(userAttrRes) ? userAttrRes.data?.items || [] : [];
userAttrItems.forEach((attr) => { userAttrItems.forEach((attr) => {
if (!attr?.code || attr.status === 'disabled') return; if (!attr?.code || attr.status === 'disabled') return;
userAttributes[attr.code] = attr.attribute || attr.code; userAttributes[attr.code] = attr.attribute || attr.code;

View File

@ -1,5 +1,5 @@
import { signedGet, isSuccess } from '@/api/client'; import { signedGet, isSuccess } from '@/api/client';
import { dropdownMap } from '@/api/dropdowns'; import { dropdownMap, fetchUserAttributes } from '@/api/dropdowns';
import { metricOptions, toDateInput } from '@/helpers/metric'; import { metricOptions, toDateInput } from '@/helpers/metric';
const STATUS = { active: 'Active', disabled: 'Disabled' }; const STATUS = { active: 'Active', disabled: 'Disabled' };
@ -159,7 +159,7 @@ const milestones = {
{ key: 'status', title: 'Status', type: 'select' }, { key: 'status', title: 'Status', type: 'select' },
], ],
formFields: async () => { formFields: async () => {
const [metrics, roles, badges, categories, images] = await Promise.all([ const [metrics, roles, badges, categories, images, userAttrItems] = await Promise.all([
metricOptions(), metricOptions(),
dropdownMap('ums', 'roles', (item) => item.name), dropdownMap('ums', 'roles', (item) => item.name),
getAllItems('recognition', 'badge_list/get_all').then(titleMap), getAllItems('recognition', 'badge_list/get_all').then(titleMap),
@ -171,7 +171,13 @@ const milestones = {
}); });
return values; return values;
}), }),
fetchUserAttributes(),
]); ]);
const userAttributes = {};
userAttrItems.forEach((attr) => {
if (!attr?.code || attr.status === 'disabled') return;
userAttributes[attr.code] = attr.attribute || attr.code;
});
return [ return [
{ key: 'title', title: 'Title', type: 'text', required: true }, { key: 'title', title: 'Title', type: 'text', required: true },
{ key: 'interval_start_date', title: 'Interval Start Date', type: 'date', 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' }, values: { all: 'All', loggedin: 'Logged In' },
}, },
{ key: 'show_units_export', title: 'Show Units Export', type: 'select', values: YES_NO }, { 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_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: 'show_combined_metrics', title: 'Show Combined Metrics', type: 'select', values: YES_NO },
{ key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS }, { key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS },
@ -353,6 +368,11 @@ const milestones = {
next.body = reportConfig.body || ''; next.body = reportConfig.body || '';
next.send_report = fromBool(next.send_report ?? false); next.send_report = fromBool(next.send_report ?? false);
next.show_units_export = next.show_units_export === 'no' ? 'no' : 'yes'; 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_normal_metrics = next.show_normal_metrics === 'no' ? 'no' : 'yes';
next.show_combined_metrics = next.show_combined_metrics === 'no' ? 'no' : 'yes'; next.show_combined_metrics = next.show_combined_metrics === 'no' ? 'no' : 'yes';
next.manager_details = toStringList(next.manager_details); next.manager_details = toStringList(next.manager_details);
@ -389,6 +409,8 @@ const milestones = {
body: (payload.body || '').trim(), body: (payload.body || '').trim(),
}; };
payload.show_units_export = payload.show_units_export === 'no' ? 'no' : 'yes'; 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_normal_metrics = payload.show_normal_metrics === 'no' ? 'no' : 'yes';
payload.show_combined_metrics = payload.show_combined_metrics === 'no' ? 'no' : 'yes'; payload.show_combined_metrics = payload.show_combined_metrics === 'no' ? 'no' : 'yes';
payload.manager_details = toStringList(payload.manager_details).length payload.manager_details = toStringList(payload.manager_details).length

View File

@ -20,6 +20,7 @@ const rcpacompetitors = {
{ title: 'Back to Products', to: '/rcpaproducts' }, { title: 'Back to Products', to: '/rcpaproducts' },
], ],
formFields: () => [ formFields: () => [
{ key: 'product_id', title: 'Product', type: 'hidden' },
{ key: 'company', title: 'Company', type: 'text', required: true }, { key: 'company', title: 'Company', type: 'text', required: true },
{ key: 'brand', title: 'Brand', type: 'text', required: true }, { key: 'brand', title: 'Brand', type: 'text', required: true },
{ key: 'title', title: 'Product Name', 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: 'nrv_value', title: 'NRV Value', type: 'number', required: true },
{ key: 'sort_order', title: 'Sort Order', type: 'number', required: true }, { key: 'sort_order', title: 'Sort Order', type: 'number', required: true },
], ],
beforeForm: (item, _mode, ctx) => ({ beforeForm: (item, _mode, ctx) => {
...item, const fromUrl = ctx?.searchParams?.get('product_id') || '';
product_id: item.product_id || 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) => { 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; if (!payload.product_id) delete payload.product_id;
return payload; return payload;
}, },

View File

@ -21,7 +21,15 @@ const rcpaforms = {
baseUrl: '/rcpaform', baseUrl: '/rcpaform',
listPath: '/rcpaforms', listPath: '/rcpaforms',
listFields: [ 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: 'title', title: 'Title', type: 'text' },
{ key: 'workflow', title: 'Workflow', type: 'text' }, { key: 'workflow', title: 'Workflow', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' }, { key: 'status', title: 'Status', type: 'select' },

View File

@ -1,5 +1,7 @@
import { dropdownMap } from '@/api/dropdowns'; import { dropdownMap } from '@/api/dropdowns';
const OPTION_TYPES = ['select', 'multiselect', 'checkbox', 'radio'];
const userattributes = { const userattributes = {
service: 'ums', service: 'ums',
module: 'userattribute', module: 'userattribute',
@ -40,7 +42,7 @@ const userattributes = {
{ key: 'roles', title: 'Roles', type: 'multiselect', values: roles }, { key: 'roles', title: 'Roles', type: 'multiselect', values: roles },
{ key: 'sort_order', title: 'Sort Order', type: 'number', required: true, default: 0 }, { 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: '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) => ({ beforeForm: (item) => ({
@ -52,11 +54,10 @@ const userattributes = {
sort_order: item.sort_order ?? 0, sort_order: item.sort_order ?? 0,
}), }),
beforeSave: (data) => { beforeSave: (data) => {
const optionTypes = ['select', 'multiselect', 'checkbox', 'radio'];
return { return {
...data, ...data,
roles: Array.isArray(data.roles) ? data.roles : [], roles: Array.isArray(data.roles) ? data.roles : [],
options: optionTypes.includes(data.type) ? data.options || [] : [], options: OPTION_TYPES.includes(data.type) ? data.options || [] : [],
}; };
}, },
}; };

View File

@ -2,6 +2,105 @@ import { signedGet, isSuccess } from '@/api/client';
import { dropdownMap } from '@/api/dropdowns'; import { dropdownMap } from '@/api/dropdowns';
import { formatRoleNames } from '@/helpers/roles'; 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 = { const users = {
service: 'ums', service: 'ums',
module: 'users', module: 'users',
@ -33,36 +132,15 @@ const users = {
exportCsv: { exportCsv: {
params: { start: 0, length: 9999, search: JSON.stringify({}), keyword: '', export: 1 }, params: { start: 0, length: 9999, search: JSON.stringify({}), keyword: '', export: 1 },
filename: 'users.csv', filename: 'users.csv',
headers: [ build: (items, formatDate) => {
'id', const extras = extraExportKeys(items);
'Employee Code', const headers = [...EXPORT_COLUMNS.map((column) => column.title), ...extras.map(formatExportHeading)];
'Firstname', const rows = (items || []).map((user) => [
'Lastname', ...EXPORT_COLUMNS.map((column) => exportColumnValue(user, column.key, formatDate)),
'Email', ...extras.map((key) => exportCell(user?.[key], formatDate, key)),
'Is Logged In', ]);
'Roles', return [headers, ...rows];
'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) : '-',
],
}, },
formFields: async () => { formFields: async () => {
const domainRes = await signedGet('ums', 'domain/get_one'); const domainRes = await signedGet('ums', 'domain/get_one');

View File

@ -85,13 +85,23 @@ const MagMediaViewers = lazy(() => import('@/views/content/MagMediaViewers'));
const WorkflowEditor = lazy(() => import('@/views/workflow/Editor')); const WorkflowEditor = lazy(() => import('@/views/workflow/Editor'));
const WorkflowMonitor = lazy(() => import('@/views/workflow/Monitor')); const WorkflowMonitor = lazy(() => import('@/views/workflow/Monitor'));
const LeaderboardDashboard = lazy(() => import('@/views/leaderboard/Dashboard')); 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) { function moduleRoutes(config) {
const routes = [ const routes = [
{ path: config.listPath.replace(/^\//, ''), element: <EntityListPage module={config} /> }, { path: config.listPath.replace(/^\//, ''), element: <EntityListPage module={config} /> },
]; ];
if (!config.skipAdd) { if (!config.skipAdd) {
routes.push({ path: `${config.baseUrl.replace(/^\//, '')}/addnew`, element: <EntityFormPage module={config} /> }); const addPath = (config.addPath || `${config.baseUrl}/addnew`).replace(/^\//, '');
routes.push({ path: addPath, element: <EntityFormPage module={config} /> });
} }
if (!config.skipEdit) { if (!config.skipEdit) {
routes.push({ path: `${config.baseUrl.replace(/^\//, '')}/edit`, element: <EntityFormPage module={config} /> }); routes.push({ path: `${config.baseUrl.replace(/^\//, '')}/edit`, element: <EntityFormPage module={config} /> });
@ -132,6 +142,8 @@ export const routes = [
{ path: '/team/players', element: <TeamPlayers /> }, { path: '/team/players', element: <TeamPlayers /> },
...moduleRoutes(contacts), ...moduleRoutes(contacts),
...moduleRoutes(contactexports), ...moduleRoutes(contactexports),
{ path: 'contactexports', element: <Navigate to="/contact/export" replace /> },
{ path: 'contactexport/addnew', element: <Navigate to="/contact/exportcreate" replace /> },
...moduleRoutes(contactattributes), ...moduleRoutes(contactattributes),
{ {
path: '/contact/import', path: '/contact/import',
@ -204,6 +216,15 @@ export const routes = [
...moduleRoutes(tracks), ...moduleRoutes(tracks),
{ path: '/metricreports', element: <MetricReportList /> }, { path: '/metricreports', element: <MetricReportList /> },
{ path: '/metricreport/report', element: <MetricReport /> }, { path: '/metricreport/report', element: <MetricReport /> },
{ path: '/neblogisticreports', element: <NebReportList /> },
{ path: '/neblogisticreport/viewreportbyid', element: <NebReportDetail /> },
{ path: '/neblogisticreport/depot', element: <NebDepotPage /> },
{ path: '/neblogisticreport/import', element: <NebImportPage /> },
{ path: '/depotwithtmmappings', element: <NebMappingPage /> },
{ path: '/rcpareport/tmwise', element: <RcpaTmWise /> },
{ path: '/rcpareport/monthwise', element: <RcpaMonthWise /> },
{ path: '/rcpareport/formwise', element: <RcpaFormWise /> },
{ path: '/gennovadcreport/dashboard', element: <GennovadcDashboard /> },
{ path: '/coming-soon', element: <ComingSoon /> }, { path: '/coming-soon', element: <ComingSoon /> },
{ path: '*', element: <ComingSoon /> }, { path: '*', element: <ComingSoon /> },
], ],

View File

@ -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 (
<Container fluid>
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2">
<PageBreadcrumb title="MYDTx Dashboard" />
<div className="d-flex align-items-center gap-2">
<HierarchyFilter options={hierarchies} selected={selectedHier} onApply={setSelectedHier} />
<Form.Select value={month} onChange={(event) => setMonth(event.target.value)} style={{ width: 170 }}>
{months.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Form.Select>
</div>
</div>
{hierarchyError ? <div className="alert alert-warning">{hierarchyError}</div> : null}
{SECTIONS.map((section, index) => {
const result = results[section.id] || {};
return (
<ReportSection
key={section.id}
title={`${index + 1}. ${section.title(month)}`}
table={result.table || EMPTY_TABLE}
loading={result.loading ?? true}
error={result.error}
filename={section.filename}
stickyFirstColumn={section.sticky}
/>
);
})}
</Container>
);
}

View File

@ -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 (
<Dropdown show={show} onToggle={toggleOpen} autoClose="outside" align="end">
<Dropdown.Toggle variant="outline-secondary" className="gennova-hier-toggle" disabled={disabled}>
{selected.length ? `Hierarchy (${selected.length})` : 'Hierarchy'}
</Dropdown.Toggle>
<Dropdown.Menu className="p-3 gennova-hier-menu">
<Form.Control
size="sm"
className="mb-2"
placeholder="Search hierarchy..."
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<div className="gennova-hier-options">
{visible.length ? (
visible.map((option) => (
<Form.Check
key={option.id}
id={`gennova-hier-${option.id}`}
type="checkbox"
label={`${option.name} (${option.type})`}
checked={draft.includes(option.id)}
onChange={() => toggleOption(option.id)}
/>
))
) : (
<div className="text-muted small py-2">No hierarchy found</div>
)}
</div>
<hr className="my-2" />
<div className="d-flex justify-content-between">
<Button size="sm" variant="light" onClick={() => setDraft([])}>
Clear
</Button>
<Button size="sm" variant="primary" onClick={apply}>
Apply
</Button>
</div>
</Dropdown.Menu>
</Dropdown>
);
}

View File

@ -0,0 +1,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 (
<Card className="mb-3">
<Card.Header className="d-flex justify-content-between align-items-center">
<h5 className="card-title mb-0">{title}</h5>
<Button size="sm" variant="primary" onClick={handleExport} disabled={loading || exporting || !columns.length}>
{exporting ? 'Exporting...' : 'Export'}
</Button>
</Card.Header>
<Card.Body className="position-relative">
{error ? <div className="alert alert-danger mb-2">{error}</div> : null}
{exportError ? <div className="alert alert-danger mb-2">{exportError}</div> : null}
{loading ? (
<div className="gennova-report-loading">
<Spinner animation="border" />
</div>
) : null}
{columns.length ? (
<div className="gennova-report-scroll">
<Table
className={clsx(
'table-centered table-nowrap mb-0 gennova-report-table',
bordered ? 'transplant-summary-table' : 'table-borderless',
)}
>
<thead className="table-light">
{headers.map((cells, rowIndex) => (
<tr key={rowIndex}>
{cells.map((cell) => (
<th
key={`${rowIndex}-${cell.column}`}
colSpan={cell.colSpan}
rowSpan={cell.rowSpan}
className={clsx(cellClass(cell.column), cell.colSpan > 1 && 'text-center')}
>
{cell.label}
</th>
))}
</tr>
))}
</thead>
<tbody>
{rows.length ? (
rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{columns.map((column, index) => (
<td key={index} className={cellClass(index)}>
{formatCell(rowValue(row, column), column.format)}
</td>
))}
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="text-center text-muted py-3">
No data
</td>
</tr>
)}
{total ? (
<tr className="table-secondary fw-bold">
{total.map((value, index) => (
<td key={index} className={cellClass(index)}>
{formatCell(value, columns[index]?.format)}
</td>
))}
</tr>
) : null}
</tbody>
</Table>
</div>
) : (
<div className="text-center text-muted py-3">{loading ? 'Loading...' : 'No data'}</div>
)}
</Card.Body>
</Card>
);
}

View File

@ -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);
}

View File

@ -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: "<json>" }. */
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];
}

View File

@ -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 (
<Container fluid>
<PageBreadcrumb title="Add / Edit Depot" subtitle="Neb Logistics" />
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
<Card className="mb-3">
<Card.Body>
<Form onSubmit={submit}>
<div className="d-flex justify-content-between mb-3">
<h4 className="mb-0">Add / Edit Depot</h4>
<div className="d-flex gap-2">
<Button type="button" variant="dark" onClick={() => setForm(empty)}>Add New</Button>
<Button type="submit" variant="success" disabled={saving}>Submit</Button>
</div>
</div>
<Row>
<Col md={6} className="mb-3">
<Form.Label>Region</Form.Label>
<Form.Control required value={form.region} onChange={(e) => setForm((p) => ({ ...p, region: e.target.value }))} />
</Col>
<Col md={6} className="mb-3">
<Form.Label>State</Form.Label>
<Form.Select required value={form.state} onChange={(e) => setForm((p) => ({ ...p, state: e.target.value }))}>
<option value=""> -- Select -- </option>
{STATES.map((state) => <option key={state}>{state}</option>)}
</Form.Select>
</Col>
<Col md={6} className="mb-3">
<Form.Label>Pincode</Form.Label>
<Form.Control required value={form.pincode} onChange={(e) => setForm((p) => ({ ...p, pincode: e.target.value }))} />
</Col>
<Col md={6} className="mb-3">
<Form.Label>Name</Form.Label>
<Form.Control required value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
</Col>
</Row>
</Form>
</Card.Body>
</Card>
<Table bordered hover responsive>
<thead className="table-dark">
<tr>
<th>Action</th>
<th>Region</th>
<th>State</th>
<th>Pincode</th>
</tr>
</thead>
<tbody>
{items.length ? items.map((item) => (
<tr key={item.id}>
<td className="d-flex gap-1 justify-content-center">
<Button size="sm" variant="primary" title="Edit" onClick={() => setForm({
id: item.id,
region: item.region || '',
state: item.state || '',
pincode: item.pincode || '',
name: item.name || '',
})}>
<TbPencil />
</Button>
<Button size="sm" variant="danger" title="Delete" onClick={() => remove(item.id)}>
<TbTrash />
</Button>
</td>
<td>{item.region}</td>
<td>{item.state}</td>
<td>{item.pincode}</td>
</tr>
)) : (
<tr><td colSpan={4} className="text-center">No records found.</td></tr>
)}
</tbody>
</Table>
</Container>
);
}

View File

@ -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 (
<Container fluid>
<PageBreadcrumb title="Import Neblogistic Data" subtitle="Neb Logistics" />
<Card>
<Card.Body>
<div className="d-flex justify-content-between mb-3">
<h4 className="mb-0">Import Neblogistic Data</h4>
<Button as={Link} to="/neblogisticreports" variant="secondary">Back to List</Button>
</div>
<div className="alert alert-info">
<h5>Import Instructions</h5>
<ul className="mb-0">
<li>Upload an Excel (.xlsx, .xls) or CSV (.csv) file on the Laravel portal.</li>
<li>The file must contain a column named <strong>Connect od Order</strong>.</li>
<li>Only ORDER NO, INVOICE NO, COURIER NAME, LR NO, LR DATE (and Status) are updated.</li>
<li>Maximum file size: 10MB.</li>
</ul>
</div>
<p className="text-muted mb-0">
File import still runs on Laravel because it writes the MySQL <code>neblogistic</code> table.
Open <code>/neblogisticreport/import</code> on the Laravel app to upload the file.
</p>
</Card.Body>
</Card>
</Container>
);
}

View File

@ -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 (
<Container fluid>
<PageBreadcrumb title="Depot & TM Mapping" subtitle="Neb Logistics" />
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
<Card className="mb-3">
<Card.Body>
<Form onSubmit={submit}>
<div className="d-flex justify-content-between mb-3">
<h4 className="mb-0">Add / Edit Mapping</h4>
<div className="d-flex gap-2">
<Button type="button" variant="dark" onClick={() => setForm(empty)}>Add New</Button>
<Button type="submit" variant="success" disabled={saving}>Submit</Button>
</div>
</div>
<Row>
<Col md={6} className="mb-3">
<Form.Label>Depot</Form.Label>
<Form.Select required value={form.depot_id} onChange={(e) => setForm((p) => ({ ...p, depot_id: e.target.value }))}>
<option value=""> -- Select -- </option>
{depots.map((depot) => (
<option key={depot.id} value={depot.id}>{depot.name || depot.region} ({depot.state})</option>
))}
</Form.Select>
</Col>
<Col md={6} className="mb-3">
<Form.Label>Emp ID of TM</Form.Label>
<Form.Control required value={form.emp_id_tm} onChange={(e) => setForm((p) => ({ ...p, emp_id_tm: e.target.value }))} />
</Col>
<Col md={6} className="mb-3">
<Form.Label>Name of TM</Form.Label>
<Form.Control required value={form.tm_name} onChange={(e) => setForm((p) => ({ ...p, tm_name: e.target.value }))} />
</Col>
<Col md={6} className="mb-3">
<Form.Label>Email of TM</Form.Label>
<Form.Control type="email" value={form.tm_email} onChange={(e) => setForm((p) => ({ ...p, tm_email: e.target.value }))} />
</Col>
<Col md={6} className="mb-3">
<Form.Label>Hierarchy ID</Form.Label>
<Form.Control value={form.hierarchy_id} onChange={(e) => setForm((p) => ({ ...p, hierarchy_id: e.target.value }))} />
</Col>
</Row>
</Form>
</Card.Body>
</Card>
<Table bordered hover responsive>
<thead>
<tr>
<th>Actions</th>
<th>Depot</th>
<th>TM Emp ID</th>
<th>TM Name</th>
<th>TM Email</th>
<th>Hierarchy ID</th>
</tr>
</thead>
<tbody>
{items.length ? items.map((item) => (
<tr key={item.id}>
<td className="d-flex gap-1">
<Button size="sm" variant="warning" onClick={() => setForm({
id: item.id,
depot_id: item.depot_id || '',
emp_id_tm: item.emp_id_tm || '',
tm_name: item.tm_name || '',
tm_email: item.tm_email || '',
hierarchy_id: item.hierarchy_id || '',
})}>
<TbPencil />
</Button>
<Button size="sm" variant="danger" onClick={() => remove(item.id)}>
<TbTrash />
</Button>
</td>
<td>{item.depot_name || item.depot_region || item.depot_id}</td>
<td>{item.emp_id_tm}</td>
<td>{item.tm_name}</td>
<td>{item.tm_email}</td>
<td>{item.hierarchy_id}</td>
</tr>
)) : (
<tr><td colSpan={6} className="text-center text-muted">No records found.</td></tr>
)}
</tbody>
</Table>
</Container>
);
}

View File

@ -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 (
<Form.Group className="mb-3">
<Form.Label>{label}</Form.Label>
{children}
</Form.Group>
);
}
function ReadRow({ label, value }) {
return (
<p className="text-muted mb-2">
{label}: <strong>{displayValue(value)}</strong>
</p>
);
}
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 <Container fluid className="py-5 text-center"><Spinner animation="border" /></Container>;
}
if (!item) {
return <Container fluid><div className="alert alert-danger">{error || 'Record not found'}</div></Container>;
}
const image = letterUrl(item.request_letter);
const canEdit = mode === 'edit';
return (
<Container fluid>
<PageBreadcrumb title={`${canEdit ? 'Edit' : 'View'} Neblogistic Report`} subtitle="Neb Logistics" />
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div>
RequestID: <strong>{item.id}</strong> <span className="text-muted">|</span> Status:{' '}
<span className="badge bg-info">{item.status}</span>
</div>
<div className="d-flex flex-wrap gap-2">
<Form.Select style={{ width: 180 }} value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">Select Status</option>
{STATUSES.map((value) => <option key={value} value={value}>{value}</option>)}
</Form.Select>
<Button size="sm" variant="dark" disabled={saving} onClick={() => save({ status, ...form })}>Change</Button>
<Button size="sm" variant="outline-dark" onClick={() => goNeighbor('prev')}>Prev</Button>
<Button size="sm" variant="outline-dark" onClick={() => goNeighbor('next')}>Next</Button>
<Button size="sm" variant="dark" as={Link} to="/neblogisticreports">Back</Button>
</div>
</div>
<Row className="g-4">
<Col md={6}>
<Card>
<Card.Body>
<h4 className="text-primary">Employee Details</h4>
{canEdit ? (
<>
<Field label="Emp ID of TM"><Form.Control value={form.emp_id} onChange={(e) => setForm((p) => ({ ...p, emp_id: e.target.value }))} /></Field>
<Field label="Name of TM"><Form.Control value={form.employee_name} onChange={(e) => setForm((p) => ({ ...p, employee_name: e.target.value }))} /></Field>
<Field label="TM Mobile Number"><Form.Control value={form.employee_phone} onChange={(e) => setForm((p) => ({ ...p, employee_phone: e.target.value }))} /></Field>
<Field label="TM Email ID"><Form.Control type="email" value={form.employee_email} onChange={(e) => setForm((p) => ({ ...p, employee_email: e.target.value }))} /></Field>
<Field label="ABM Name"><Form.Control value={form.abm_name} onChange={(e) => setForm((p) => ({ ...p, abm_name: e.target.value }))} /></Field>
<Field label="ABM Email"><Form.Control value={form.abm_email} onChange={(e) => setForm((p) => ({ ...p, abm_email: e.target.value }))} /></Field>
<Field label="RBM Name"><Form.Control value={form.rbm_name} onChange={(e) => setForm((p) => ({ ...p, rbm_name: e.target.value }))} /></Field>
</>
) : (
<>
<ReadRow label="Emp ID of TM" value={item.emp_id} />
<ReadRow label="Name of TM" value={item.emp_name} />
<ReadRow label="TM Mobile Number" value={item.emp_phone} />
<ReadRow label="TM Email ID" value={item.emp_email} />
<ReadRow label="ABM Name" value={item.abm_name} />
<ReadRow label="RBM Name" value={item.rbm_name} />
</>
)}
<h4 className="text-primary mt-4">Doctor Details</h4>
{canEdit ? (
<>
<Field label="Dr Name"><Form.Control value={form.dr_name} onChange={(e) => setForm((p) => ({ ...p, dr_name: e.target.value }))} /></Field>
<Field label="Doctor Mobile Number"><Form.Control value={form.doctor_mobile_number} onChange={(e) => setForm((p) => ({ ...p, doctor_mobile_number: e.target.value }))} /></Field>
<Field label="Speciality">
<Form.Select value={form.speciality} onChange={(e) => setForm((p) => ({ ...p, speciality: e.target.value }))}>
<option value=""> -- Select -- </option>
{Object.entries(SPECIALITIES).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</Form.Select>
</Field>
<Field label="MSL CODE of Dr"><Form.Control value={form.msl_code} onChange={(e) => setForm((p) => ({ ...p, msl_code: e.target.value }))} /></Field>
<Field label="Type of Nebulizer">
<Form.Select value={form.type_of_nebulizer} onChange={(e) => setForm((p) => ({ ...p, type_of_nebulizer: e.target.value }))}>
{['Robust', 'Mini', 'Others'].map((value) => <option key={value}>{value}</option>)}
</Form.Select>
</Field>
<Field label="Do you need more than 5 nebulizers?">
<Form.Select value={form.more_than_5_nebulizer} onChange={(e) => setForm((p) => ({ ...p, more_than_5_nebulizer: e.target.value }))}>
<option>Yes</option>
<option>No</option>
</Form.Select>
</Field>
<Field label="No of Nebulizer"><Form.Control value={form.no_of_nebulizer} onChange={(e) => setForm((p) => ({ ...p, no_of_nebulizer: e.target.value }))} /></Field>
<Field label="Value of the Parcel"><Form.Control value={form.value_of_the_parcel} onChange={(e) => setForm((p) => ({ ...p, value_of_the_parcel: e.target.value }))} /></Field>
</>
) : (
<>
<ReadRow label="Dr Name" value={item.doctor_name} />
<ReadRow label="Doctor Mobile Number" value={item.doctor_phone} />
<ReadRow label="Speciality" value={item.doctor_speciality} />
<ReadRow label="MSL CODE of Dr" value={item.msl_code} />
<ReadRow label="Type of Nebulizer" value={item.type_of_nebulizer} />
<ReadRow label="Do you need more than 5 nebulizers" value={item.more_than_5_nebulizer} />
<ReadRow label="No of Nebulizer" value={item.no_of_nebulizer} />
<ReadRow label="Value of the Parcel" value={item.value_of_parcel} />
<ReadRow label="Team" value={item.team} />
</>
)}
</Card.Body>
</Card>
</Col>
<Col md={6}>
{image ? (
<Card className="mb-4">
<Card.Body>
<h4 className="text-primary">Request Letter</h4>
<img src={image} alt="Request Letter" className="img-fluid rounded border mb-2" style={{ maxHeight: 300, objectFit: 'contain' }} />
<a href={image} download>Download existing</a>
</Card.Body>
</Card>
) : null}
<Card>
<Card.Body>
<h4 className="text-primary">Drop Details</h4>
{canEdit ? (
<>
<Field label="Clinic / Hospital / Home Name"><Form.Control value={form.clinic_hospital_home_name} onChange={(e) => setForm((p) => ({ ...p, clinic_hospital_home_name: e.target.value }))} /></Field>
<Field label="Building Name"><Form.Control value={form.building_name} onChange={(e) => setForm((p) => ({ ...p, building_name: e.target.value }))} /></Field>
<Field label="Address Type"><Form.Control value={form.address_type} onChange={(e) => setForm((p) => ({ ...p, address_type: e.target.value }))} /></Field>
<Field label="Address line 1"><Form.Control value={form.address_line_1} onChange={(e) => setForm((p) => ({ ...p, address_line_1: e.target.value }))} /></Field>
<Field label="Address line 2"><Form.Control value={form.address_line_2} onChange={(e) => setForm((p) => ({ ...p, address_line_2: e.target.value }))} /></Field>
<Field label="State"><Form.Control value={form.state} onChange={(e) => setForm((p) => ({ ...p, state: e.target.value }))} /></Field>
<Field label="City"><Form.Control value={form.city} onChange={(e) => setForm((p) => ({ ...p, city: e.target.value }))} /></Field>
<Field label="Pin Code"><Form.Control value={form.pin_code} onChange={(e) => setForm((p) => ({ ...p, pin_code: e.target.value }))} /></Field>
<Field label="Depot"><Form.Control value={form.depot_name} onChange={(e) => setForm((p) => ({ ...p, depot_name: e.target.value }))} /></Field>
<Field label="Team"><Form.Control value={form.team} onChange={(e) => setForm((p) => ({ ...p, team: e.target.value }))} /></Field>
<Button disabled={saving} onClick={() => save({ ...form, status })}>Save Details</Button>
</>
) : (
<>
<ReadRow label="Clinic / Hospital / Home Name" value={item.clinic_hospital_name} />
<ReadRow label="Building Name" value={item.address_building} />
<ReadRow label="Address Type" value={item.address_type} />
<ReadRow label="Address line 1" value={item.address_line_1} />
<ReadRow label="Address line 2" value={item.address_line_2} />
<ReadRow label="State" value={item.address_state} />
<ReadRow label="City" value={item.address_city} />
<ReadRow label="Pin Code" value={item.address_pin_code} />
</>
)}
</Card.Body>
</Card>
{['Dispatched', 'Delivered'].includes(item.status) || status === 'Dispatched' ? (
<Card className="mt-4">
<Card.Body>
<h4 className="text-primary">Dispatch / Delivery Details</h4>
{canEdit ? (
<>
<Field label="Order No"><Form.Control value={form.order_no} onChange={(e) => setForm((p) => ({ ...p, order_no: e.target.value }))} /></Field>
<Field label="Invoice Number"><Form.Control value={form.invoice_no} onChange={(e) => setForm((p) => ({ ...p, invoice_no: e.target.value }))} /></Field>
<Field label="Courier Name"><Form.Control value={form.courier_name} onChange={(e) => setForm((p) => ({ ...p, courier_name: e.target.value }))} /></Field>
<Field label="LR Number"><Form.Control value={form.lr_no} onChange={(e) => setForm((p) => ({ ...p, lr_no: e.target.value }))} /></Field>
<Field label="LR Date"><Form.Control type="date" value={form.lr_date} onChange={(e) => setForm((p) => ({ ...p, lr_date: e.target.value }))} /></Field>
</>
) : (
<>
<ReadRow label="Invoice Number" value={item.invoice_no} />
<ReadRow label="Courier Name" value={item.courier_name} />
<ReadRow label="LR Number" value={item.lr_no} />
<ReadRow label="LR Date" value={item.lr_date} />
</>
)}
</Card.Body>
</Card>
) : null}
{status === 'Rejected' && canEdit ? (
<Card className="mt-4">
<Card.Body>
<Field label="Rejected Reason"><Form.Control as="textarea" rows={3} value={form.rejected_reason} onChange={(e) => setForm((p) => ({ ...p, rejected_reason: e.target.value }))} /></Field>
</Card.Body>
</Card>
) : null}
</Col>
</Row>
{doctors.length ? (
<Card className="mt-4">
<Card.Body>
<h4 className="text-primary">Other requests with same MSL</h4>
{doctors.map((row) => (
<div key={row.id} className="small text-muted">{row.id} — {row.emp_name} — {row.status}</div>
))}
</Card.Body>
</Card>
) : null}
</Container>
);
}

View File

@ -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 (
<Container fluid>
<PageBreadcrumb title="Neblogistic Report Management" subtitle="Neb Logistics" />
<div className="d-flex justify-content-end gap-2 mb-3">
<Button as="a" href={exportHref} variant="dark">Export</Button>
<Button as={Link} to="/neblogisticreport/import" variant="dark">Import</Button>
{!filters.isDepotManager && !filters.isSuperDepotManager ? (
<Button as="a" href={`${exportHref}&approved_only=1`} variant="outline-dark">Export Old</Button>
) : null}
</div>
<Card className="mb-3">
<Card.Body>
<Row className="g-2 align-items-end">
{!hideDepot ? (
<Col lg={3} md={4}>
<Form.Label>Depot</Form.Label>
<Form.Select value={query.state} onChange={(e) => setQuery((prev) => ({ ...prev, state: e.target.value }))}>
<option value="">-- All Depots --</option>
{(filters.depot || []).map((depot) => (
<option key={depot} value={depot}>{depot}</option>
))}
</Form.Select>
</Col>
) : null}
<Col lg={3} md={4}>
<Form.Label>Status</Form.Label>
<Form.Select value={query.status} onChange={(e) => setQuery((prev) => ({ ...prev, status: e.target.value }))}>
{!filters.isDepotManager && !filters.isSuperDepotManager ? <option value="">-- All --</option> : null}
{statusOptions.map((status) => (
<option key={status} value={status}>{status}</option>
))}
</Form.Select>
</Col>
<Col lg={2} md={4}>
<Form.Label>From</Form.Label>
<Form.Control type="date" value={query.from_date} onChange={(e) => setQuery((prev) => ({ ...prev, from_date: e.target.value }))} />
</Col>
<Col lg={2} md={4}>
<Form.Label>To</Form.Label>
<Form.Control type="date" value={query.to_date} onChange={(e) => setQuery((prev) => ({ ...prev, to_date: e.target.value }))} />
</Col>
<Col lg={2} md={4}>
<Button
className="w-100"
onClick={() => {
setPage(0);
setApplied(query);
}}
>
Fetch
</Button>
</Col>
</Row>
</Card.Body>
</Card>
{error ? <div className="alert alert-danger">{error}</div> : null}
<Card>
<Card.Body>
<div className="d-flex justify-content-between align-items-center mb-2">
<Form.Control
style={{ maxWidth: 280 }}
placeholder="Search"
value={query.search}
onChange={(e) => setQuery((prev) => ({ ...prev, search: e.target.value }))}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setPage(0);
setApplied((prev) => ({ ...prev, search: query.search }));
}
}}
/>
<div className="text-muted small">{count} records</div>
</div>
{loading ? (
<div className="py-5 text-center"><Spinner animation="border" /></div>
) : (
<Table responsive hover>
<thead>
<tr>
<th>Id</th>
<th>Employee Number</th>
<th>Employee Name</th>
<th>Team</th>
<th>Depot Name</th>
<th>Doctor Name</th>
<th>MSL Code</th>
<th>Status</th>
<th>Request Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={row.id}>
<td>{row.id}</td>
<td>{row.emp_id}</td>
<td>{row.emp_name}</td>
<td>{row.team}</td>
<td>{row.depot_name}</td>
<td>{row.doctor_name}</td>
<td>{row.msl_code}</td>
<td>{row.status}</td>
<td>{formatReportDate(row.created_at)}</td>
<td className="d-flex gap-1">
{!filters.isDepotManager && !filters.isSuperDepotManager ? (
<Button as={Link} size="sm" variant="warning" to={`/neblogisticreport/viewreportbyid?${params}&requesttype=edit`} title="Edit">
<TbPencil />
</Button>
) : null}
<Button as={Link} size="sm" variant="info" to={`/neblogisticreport/viewreportbyid?${params}&requesttype=view`} title="View">
<TbEye />
</Button>
</td>
</tr>
);
}) : (
<tr><td colSpan={10} className="text-center text-muted">No records found.</td></tr>
)}
</tbody>
</Table>
)}
<div className="d-flex justify-content-between align-items-center">
<Form.Select style={{ width: 120 }} value={pageSize} onChange={(e) => { setPage(0); setPageSize(Number(e.target.value)); }}>
{[200, 350, 500, 750, 900].map((size) => <option key={size} value={size}>{size}</option>)}
</Form.Select>
<div className="d-flex gap-2">
<Button size="sm" variant="light" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}>Prev</Button>
<Button size="sm" variant="light" disabled={(page + 1) * pageSize >= count} onClick={() => setPage((prev) => prev + 1)}>Next</Button>
</div>
</div>
</Card.Body>
</Card>
</Container>
);
}

View File

@ -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);
}

View File

@ -71,7 +71,14 @@ const emptyChild = {
channels: '', 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() { function createExpandStore() {
let state = {}; let state = {};
@ -249,10 +256,15 @@ export default function HierarchyTree() {
if (!parentId || isCrestroad()) return []; if (!parentId || isCrestroad()) return [];
setLoadingIds((prev) => ({ ...prev, [parentId]: true })); setLoadingIds((prev) => ({ ...prev, [parentId]: true }));
try { try {
const response = await signedGet('ums', 'hierarchy/get_all', { const response = await signedGet(
...CHILDREN_PARAMS, 'ums',
parent_id: parentId, 'hierarchy/get_all',
}); {
...CHILDREN_PARAMS,
parent_id: parentId,
},
{ timeout: 120000 },
);
if (response?.statusCode === 401 || response?.statusCode === 403) { if (response?.statusCode === 401 || response?.statusCode === 403) {
if (!silent) setError('Unauthorised'); if (!silent) setError('Unauthorised');
return []; return [];
@ -261,7 +273,7 @@ export default function HierarchyTree() {
if (!silent) setError(response?.message || 'Unable to load hierarchy'); if (!silent) setError(response?.message || 'Unable to load hierarchy');
return []; return [];
} }
const items = response.data?.items || []; const items = hierarchyItems(response.data);
mergeChildren(parentId, items); mergeChildren(parentId, items);
return items; return items;
} catch (err) { } catch (err) {
@ -285,7 +297,7 @@ export default function HierarchyTree() {
const crestroad = isCrestroad(); const crestroad = isCrestroad();
const listRes = crestroad const listRes = crestroad
? await signedGet('catalog', 'activity/hierarchy_list') ? 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) { if (listRes?.statusCode === 401 || listRes?.statusCode === 403) {
setError('Unauthorised'); setError('Unauthorised');
return; return;
@ -295,7 +307,7 @@ export default function HierarchyTree() {
return; return;
} }
const me = listRes.data?.me; const me = listRes.data?.me;
const items = listRes.data?.items || []; const items = hierarchyItems(listRes.data);
if (crestroad) { if (crestroad) {
const ingested = ingestFullTree(items, me); const ingested = ingestFullTree(items, me);
setNodesById(ingested.nodes); setNodesById(ingested.nodes);
@ -371,10 +383,10 @@ export default function HierarchyTree() {
expandStore.collapse(id); expandStore.collapse(id);
return; return;
} }
expandStore.expand(id);
if (!isCrestroad() && childrenByIdRef.current[id] === undefined) { if (!isCrestroad() && childrenByIdRef.current[id] === undefined) {
await fetchChildren(id); await fetchChildren(id);
} }
expandStore.expand(id);
}, },
[expandStore, fetchChildren], [expandStore, fetchChildren],
); );

View File

@ -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 (
<Container fluid>
<PageBreadcrumb title="Form Wise RCPA Report" />
{error ? <div className="alert alert-danger">{error}</div> : null}
<Card className="mb-3">
<Card.Body className="d-flex flex-nowrap align-items-end justify-content-between gap-2">
<Form
className="d-flex flex-wrap align-items-end gap-2 rcpa-report-filters"
onSubmit={(event) => {
event.preventDefault();
fetchReport();
}}
>
<div>
<Form.Label className="mb-1">Form</Form.Label>
<Form.Select
value={formId}
onChange={(event) => setFormId(event.target.value)}
disabled={loadingForms}
style={{ minWidth: 220 }}
>
<option value="">All Forms</option>
{forms.map((form) => (
<option key={form.id} value={form.id}>
{form.title || form.id}
</option>
))}
</Form.Select>
</div>
<div>
<Form.Label className="mb-1">From</Form.Label>
<Form.Control type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} />
</div>
<div>
<Form.Label className="mb-1">To</Form.Label>
<Form.Control type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} />
</div>
<Button type="submit" variant="info" disabled={loading || loadingForms}>
{loading ? 'Fetching...' : 'Fetch'}
</Button>
</Form>
<Button size="sm" variant="primary" className="fw-bold flex-shrink-0 mb-1" onClick={() => gridRef.current?.exportCsv()} disabled={loading}>
Export to Excel
</Button>
</Card.Body>
</Card>
<ReportGrid
ref={gridRef}
columns={columns}
rows={rows}
loading={loading}
filename="rcpa-formwise.csv"
emptyDescription="Choose a form and date range, then click Fetch"
/>
</Container>
);
}

View File

@ -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 (
<Container fluid>
<PageBreadcrumb title="Month Wise RCPA Report" />
{error ? <div className="alert alert-danger">{error}</div> : null}
<Card className="mb-3">
<Card.Body className="d-flex flex-nowrap align-items-center justify-content-between gap-2">
<div className="d-flex flex-nowrap align-items-center gap-2 rcpa-report-filters">
<Form.Label className="mb-0">SKU</Form.Label>
<Form.Select
value={skuId}
onChange={(event) => setSkuId(event.target.value)}
style={{ maxWidth: 280 }}
disabled={!skus.length && !skuId}
>
{!skus.length ? <option value={skuId || DEFAULT_SKU}>{skuId || DEFAULT_SKU}</option> : null}
{skus.map((sku) => (
<option key={sku.id} value={sku.id}>
{sku.title || sku.id}
</option>
))}
</Form.Select>
</div>
<Button size="sm" variant="primary" className="fw-bold flex-shrink-0" onClick={() => gridRef.current?.exportCsv()} disabled={loading}>
Export to Excel
</Button>
</Card.Body>
</Card>
<ReportGrid
ref={gridRef}
columns={columns}
rows={rows}
loading={loading}
filename="rcpa-monthwise.csv"
emptyDescription="No month wise RCPA data for the selected SKU"
highlightBase
/>
</Container>
);
}

View File

@ -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 = `<span class="ag-overlay-no-rows-center">${emptyDescription}</span>`;
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 (
<Card>
<Card.Body className="position-relative">
{loading ? (
<div className="rcpa-report-loading">
<Spinner animation="border" />
</div>
) : null}
<div id="myGrid" className="ag-theme-alpine rcpa-report-grid">
<AgGridReact
ref={gridRef}
columnDefs={columnDefs}
rowData={rows || []}
defaultColDef={defaultColDef}
pagination
paginationPageSize={50}
paginationPageSizeSelector={false}
domLayout="normal"
overlayNoRowsTemplate={overlayNoRowsTemplate}
suppressCellFocus
/>
</div>
</Card.Body>
</Card>
);
});
export default ReportGrid;

View File

@ -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 (
<Container fluid>
<PageBreadcrumb title="TM Wise RCPA Report" />
{error ? <div className="alert alert-danger">{error}</div> : null}
<Card className="mb-3">
<Card.Body className="d-flex flex-nowrap align-items-center justify-content-between gap-2">
<div className="d-flex flex-nowrap align-items-center gap-2 rcpa-report-filters">
<Form.Label className="mb-0">Month</Form.Label>
<Form.Select
value={month}
onChange={(event) => setMonth(event.target.value)}
style={{ maxWidth: 220 }}
>
{months.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</Form.Select>
</div>
<Button size="sm" variant="primary" className="fw-bold flex-shrink-0" onClick={() => gridRef.current?.exportCsv()} disabled={loading}>
Export to Excel
</Button>
</Card.Body>
</Card>
<ReportGrid
ref={gridRef}
columns={columns}
rows={rows}
loading={loading}
filename="rcpa-tmwise.csv"
emptyDescription="No TM wise RCPA data for the selected month"
/>
</Container>
);
}

View File

@ -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}`;
}