## 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:
parent
16c0ef827c
commit
7355130e02
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 || '';
|
||||||
|
|
|
||||||
|
|
@ -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 || '',
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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 || [] : [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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 /> },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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}`;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue