Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
22c4391380
|
|
@ -0,0 +1,2 @@
|
|||
VITE_API_MODE=local
|
||||
VITE_LARAVEL_ORIGIN=http://127.0.0.1:8000
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
*.cursor/
|
||||
|
|
@ -5,6 +5,7 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:local": "vite --mode localapi",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
|
|
@ -26,6 +27,8 @@
|
|||
"@rc-component/tour": "^2.1.6",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@xyflow/react": "^12.11.6",
|
||||
"ag-grid-community": "^32.3.9",
|
||||
"ag-grid-react": "^32.3.9",
|
||||
"apexcharts": "^4.7.0",
|
||||
"apextree": "^1.3.0",
|
||||
"axios": "^1.11.0",
|
||||
|
|
@ -44,6 +47,7 @@
|
|||
"datatables.net-select-bs5": "^3.0.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"exceljs": "^4.4.0",
|
||||
"filepond": "^4.32.8",
|
||||
"filepond-plugin-image-exif-orientation": "^1.0.11",
|
||||
"filepond-plugin-image-preview": "^4.6.12",
|
||||
|
|
|
|||
|
|
@ -84,16 +84,25 @@ async function request(method, service, path, data = {}, { signed = true, timeou
|
|||
const payload = error.response?.data;
|
||||
const apiStatus = payload?.statusCode;
|
||||
if (signed && (status === 401 || apiStatus === 401)) {
|
||||
let token;
|
||||
try {
|
||||
token = await ensureRefresh();
|
||||
} catch {
|
||||
clearAndLogout();
|
||||
return payload || { statusCode: 401, status: 'error', message: 'Unauthorised' };
|
||||
}
|
||||
try {
|
||||
const token = await ensureRefresh();
|
||||
const retry = await axios({
|
||||
...config,
|
||||
headers: { ...headers, Authorization: token },
|
||||
});
|
||||
return retry.data;
|
||||
} catch {
|
||||
clearAndLogout();
|
||||
return payload || { statusCode: 401, status: 'error', message: 'Unauthorised' };
|
||||
} catch (retryError) {
|
||||
return (
|
||||
retryError.response?.data ||
|
||||
payload ||
|
||||
{ statusCode: 401, status: 'error', message: 'Unauthorised' }
|
||||
);
|
||||
}
|
||||
}
|
||||
if (payload) return payload;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ export const LOCAL_SERVICES = {
|
|||
export const isLocalApi = () => import.meta.env.VITE_API_MODE === 'local';
|
||||
|
||||
export function resolveUrl(service, path) {
|
||||
if (isLocalApi()) {
|
||||
if (isLocalApi() || import.meta.env.DEV) {
|
||||
return `/api/${service}/${path}`;
|
||||
}
|
||||
// Do not call Lambda Function URLs from the browser. Their CORS config
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
import { signedGet, isSuccess } from '@/api/client';
|
||||
import { loadRoleMap } from '@/helpers/roles';
|
||||
|
||||
export async function fetchUserAttributes() {
|
||||
try {
|
||||
const response = await signedGet('ums', 'userattribute/get_all');
|
||||
if (!isSuccess(response)) return [];
|
||||
return Array.isArray(response.data?.items) ? response.data.items : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function dropdownMap(service, module, labelFn, keyFn) {
|
||||
if (service === 'ums' && module === 'roles' && !keyFn) {
|
||||
return loadRoleMap();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -94,4 +94,6 @@
|
|||
@import "plugins/react-select";
|
||||
@import "plugins/react-slider";
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
|
@ -14,15 +14,22 @@ import {
|
|||
} from '@/components/metric/MetricFieldControls';
|
||||
import MilestoneMappingField from '@/components/recognition/MilestoneMappingField';
|
||||
import EmailNotificationRulesField from '@/components/form/EmailNotificationRulesField';
|
||||
import { DataDesignerField, normalizeDataDesigner } from '@/components/form/DataDesignerField';
|
||||
import RankRolesField from '@/components/form/RankRolesField';
|
||||
import ReminderRowsField from '@/components/form/ReminderRowsField';
|
||||
import DependentSelectField from '@/components/form/DependentSelectField';
|
||||
import BigMultiSelectField from '@/components/form/BigMultiSelectField';
|
||||
import TemplatePositionsField from '@/components/form/TemplatePositionsField';
|
||||
import S3FileField from '@/components/S3FileField';
|
||||
import { parseCombinedMetrics } from '@/helpers/metric';
|
||||
|
||||
function toOptions(values = {}) {
|
||||
return Object.entries(values).map(([value, label]) => ({ value, label }));
|
||||
function toOptions(values = {}, pinFirst) {
|
||||
const entries = Object.entries(values);
|
||||
const mapped = (list) => list.map(([value, label]) => ({ value, label }));
|
||||
if (pinFirst == null || pinFirst === '') return mapped(entries);
|
||||
const pinned = entries.filter(([value]) => String(value) === String(pinFirst));
|
||||
const rest = entries.filter(([value]) => String(value) !== String(pinFirst));
|
||||
return mapped([...pinned, ...rest]);
|
||||
}
|
||||
|
||||
function defaultValue(field, item) {
|
||||
|
|
@ -30,7 +37,8 @@ function defaultValue(field, item) {
|
|||
const raw = fromItem !== undefined && fromItem !== null ? fromItem : field.default;
|
||||
if (raw === undefined || raw === null) {
|
||||
if (field.type === 'multiselect' || field.type === 'bigmultiselect' || field.type === 'checkbox') return [];
|
||||
if (field.type === 'mapping' || field.type === 'color_ranges' || field.type === 'ranking_attributes' || field.type === 'combined_metrics' || field.type === 'milestonemapping' || field.type === 'email_notifications' || field.type === 'rank_roles' || field.type === 'reminders') {
|
||||
if (field.type === 'data_designer') return { fields: [] };
|
||||
if (field.type === 'mapping' || field.type === 'color_ranges' || field.type === 'ranking_attributes' || field.type === 'combined_metrics' || field.type === 'milestonemapping' || field.type === 'email_notifications' || field.type === 'rank_roles' || field.type === 'reminders' || field.type === 'template_positions') {
|
||||
return [];
|
||||
}
|
||||
if (field.type === 'approverlist') return ['l1manager'];
|
||||
|
|
@ -38,6 +46,7 @@ function defaultValue(field, item) {
|
|||
return '';
|
||||
}
|
||||
if (field.type === 'combined_metrics') return parseCombinedMetrics(raw);
|
||||
if (field.type === 'data_designer') return normalizeDataDesigner(raw);
|
||||
if (field.type === 'email_notifications') {
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
try {
|
||||
|
|
@ -49,7 +58,7 @@ function defaultValue(field, item) {
|
|||
}
|
||||
return Array.isArray(raw) ? raw : [];
|
||||
}
|
||||
if ((field.type === 'mapping' || field.type === 'color_ranges' || field.type === 'ranking_attributes' || field.type === 'milestonemapping' || field.type === 'rank_roles' || field.type === 'reminders') && !Array.isArray(raw)) {
|
||||
if ((field.type === 'mapping' || field.type === 'color_ranges' || field.type === 'ranking_attributes' || field.type === 'milestonemapping' || field.type === 'rank_roles' || field.type === 'reminders' || field.type === 'template_positions') && !Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
if (field.type === 'multiselect' || field.type === 'bigmultiselect') {
|
||||
|
|
@ -94,12 +103,28 @@ function defaultValue(field, item) {
|
|||
return raw === undefined || raw === null ? '' : String(raw);
|
||||
}
|
||||
|
||||
function toColorPickerValue(value, fallback = '#ffffff') {
|
||||
const raw = String(value || '').trim();
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(raw)) return raw;
|
||||
if (/^#[0-9A-Fa-f]{3}$/.test(raw)) {
|
||||
return `#${raw[1]}${raw[1]}${raw[2]}${raw[2]}${raw[3]}${raw[3]}`;
|
||||
}
|
||||
if (/^[0-9A-Fa-f]{6}$/.test(raw)) return `#${raw}`;
|
||||
const fallbackRaw = String(fallback || '').trim();
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(fallbackRaw)) return fallbackRaw;
|
||||
return '#ffffff';
|
||||
}
|
||||
|
||||
function isFieldVisible(field, values) {
|
||||
if (field.workflowOnly && values?.workflow !== 'yes') return false;
|
||||
if (!field.showIf) return true;
|
||||
return Object.entries(field.showIf).every(([key, expected]) => {
|
||||
const actual = values?.[key];
|
||||
if (Array.isArray(expected)) return expected.includes(actual);
|
||||
if (Array.isArray(expected)) {
|
||||
if (Array.isArray(actual)) return expected.some((value) => actual.map(String).includes(String(value)));
|
||||
return expected.map(String).includes(String(actual));
|
||||
}
|
||||
if (Array.isArray(actual)) return actual.map(String).includes(String(expected));
|
||||
return actual === expected;
|
||||
});
|
||||
}
|
||||
|
|
@ -114,7 +139,7 @@ function applyExclusive(next, previous, exclusiveValue) {
|
|||
return next;
|
||||
}
|
||||
|
||||
function FieldControl({ field, control, register, moduleName, onUploadingChange }) {
|
||||
function FieldControl({ field, control, register, watch, moduleName, onUploadingChange }) {
|
||||
const common = { className: 'form-control', id: `field-${field.key}` };
|
||||
if (field.type === 'hidden') {
|
||||
return <input type="hidden" {...register(field.key)} />;
|
||||
|
|
@ -224,9 +249,10 @@ function FieldControl({ field, control, register, moduleName, onUploadingChange
|
|||
<div className="d-flex gap-2 align-items-center">
|
||||
<Form.Control
|
||||
type="color"
|
||||
value={rhf.value || '#000000'}
|
||||
className="form-control-color"
|
||||
value={toColorPickerValue(rhf.value, field.pickerDefault || '#ffffff')}
|
||||
onChange={(e) => rhf.onChange(e.target.value)}
|
||||
style={{ width: 50, padding: 2 }}
|
||||
style={{ width: 50, height: 38, padding: 2, colorScheme: 'light' }}
|
||||
/>
|
||||
<Form.Control
|
||||
value={rhf.value || ''}
|
||||
|
|
@ -240,10 +266,35 @@ function FieldControl({ field, control, register, moduleName, onUploadingChange
|
|||
}
|
||||
if (field.type === 'yesno') {
|
||||
return (
|
||||
<div>
|
||||
<Form.Check inline type="radio" label="Yes" value="yes" {...register(field.key, { required: field.required })} />
|
||||
<Form.Check inline type="radio" label="No" value="no" {...register(field.key, { required: field.required })} />
|
||||
</div>
|
||||
<Controller
|
||||
name={field.key}
|
||||
control={control}
|
||||
rules={{ required: field.required }}
|
||||
render={({ field: rhf }) => (
|
||||
<div>
|
||||
<Form.Check
|
||||
inline
|
||||
type="radio"
|
||||
id={`field-${field.key}-yes`}
|
||||
name={field.key}
|
||||
label="Yes"
|
||||
value="yes"
|
||||
checked={rhf.value === 'yes'}
|
||||
onChange={() => rhf.onChange('yes')}
|
||||
/>
|
||||
<Form.Check
|
||||
inline
|
||||
type="radio"
|
||||
id={`field-${field.key}-no`}
|
||||
name={field.key}
|
||||
label="No"
|
||||
value="no"
|
||||
checked={rhf.value === 'no'}
|
||||
onChange={() => rhf.onChange('no')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.type === 'radio') {
|
||||
|
|
@ -264,18 +315,40 @@ function FieldControl({ field, control, register, moduleName, onUploadingChange
|
|||
}
|
||||
if (field.type === 'checkbox') {
|
||||
return (
|
||||
<div>
|
||||
{Object.entries(field.values || {}).map(([value, label]) => (
|
||||
<Form.Check key={value} type="checkbox" label={label} value={value} {...register(field.key)} />
|
||||
))}
|
||||
</div>
|
||||
<Controller
|
||||
name={field.key}
|
||||
control={control}
|
||||
render={({ field: rhf }) => {
|
||||
const selected = Array.isArray(rhf.value) ? rhf.value.map(String) : [];
|
||||
return (
|
||||
<div className="d-flex flex-wrap align-items-center gap-3">
|
||||
{Object.entries(field.values || {}).map(([value, label]) => (
|
||||
<Form.Check
|
||||
key={value}
|
||||
inline
|
||||
type="checkbox"
|
||||
id={`field-${field.key}-${value}`}
|
||||
label={label}
|
||||
checked={selected.includes(String(value))}
|
||||
onChange={(event) => {
|
||||
const next = event.target.checked
|
||||
? [...selected, value]
|
||||
: selected.filter((item) => item !== String(value));
|
||||
rhf.onChange(next);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.type === 'bigmultiselect') {
|
||||
return <BigMultiSelectField field={field} control={control} />;
|
||||
}
|
||||
if (field.type === 'multiselect') {
|
||||
const options = toOptions(field.values);
|
||||
const options = toOptions(field.values, field.exclusiveValue || field.pinFirst);
|
||||
return (
|
||||
<Controller
|
||||
name={field.key}
|
||||
|
|
@ -405,12 +478,25 @@ function FieldControl({ field, control, register, moduleName, onUploadingChange
|
|||
if (field.type === 'email_notifications') {
|
||||
return <EmailNotificationRulesField field={field} control={control} />;
|
||||
}
|
||||
if (field.type === 'data_designer') {
|
||||
return <DataDesignerField field={field} control={control} />;
|
||||
}
|
||||
if (field.type === 'rank_roles') {
|
||||
return <RankRolesField field={field} control={control} />;
|
||||
}
|
||||
if (field.type === 'reminders') {
|
||||
return <ReminderRowsField field={field} control={control} />;
|
||||
}
|
||||
if (field.type === 'template_positions') {
|
||||
return (
|
||||
<TemplatePositionsField
|
||||
field={field}
|
||||
control={control}
|
||||
moduleName={moduleName}
|
||||
onUploadingChange={onUploadingChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.type === 'dependent_select') {
|
||||
return <DependentSelectField field={field} control={control} watch={watch} />;
|
||||
}
|
||||
|
|
@ -421,7 +507,9 @@ export default function EntityForm({ module: config }) {
|
|||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
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 singleton = Boolean(config.singleton);
|
||||
const mode = singleton || id ? 'edit' : 'new';
|
||||
|
|
@ -581,7 +669,7 @@ export default function EntityForm({ module: config }) {
|
|||
{fields.map((field) => {
|
||||
if (!isFieldVisible(field, watched)) return null;
|
||||
return field.type === 'hidden' ? (
|
||||
<FieldControl key={field.key} field={field} control={control} register={register} moduleName={config.module} onUploadingChange={onUploadingChange} />
|
||||
<FieldControl key={field.key} field={field} control={control} register={register} watch={watch} moduleName={config.module} onUploadingChange={onUploadingChange} />
|
||||
) : (
|
||||
<Col md={field.fullwidth ? 12 : 6} className="mb-3 min-w-0" key={field.key}>
|
||||
{field.hide_label ? null : (
|
||||
|
|
@ -589,7 +677,7 @@ export default function EntityForm({ module: config }) {
|
|||
{field.title} {field.required ? <span className="text-danger">*</span> : null}
|
||||
</Form.Label>
|
||||
)}
|
||||
<FieldControl field={field} control={control} register={register} moduleName={config.module} onUploadingChange={onUploadingChange} />
|
||||
<FieldControl field={field} control={control} register={register} watch={watch} moduleName={config.module} onUploadingChange={onUploadingChange} />
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -335,7 +335,10 @@ export default function EntityList({ module: config }) {
|
|||
if (config.listFields.some(isRoleListField)) {
|
||||
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`);
|
||||
setMessage('Export downloaded');
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,334 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import { Button, Dropdown, Form } from 'react-bootstrap';
|
||||
import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { signedGet, isSuccess } from '@/api/client';
|
||||
|
||||
const DEFAULT_HIERARCHY_LEVELS = ['Country', 'Zone', 'Region', 'Area', 'Territory'];
|
||||
const MANAGER_LEVELS = [
|
||||
['l1', 'L1'],
|
||||
['l2', 'L2'],
|
||||
['l3', 'L3'],
|
||||
];
|
||||
const MANAGER_KEYS = [
|
||||
['name', 'Name'],
|
||||
['email', 'Email'],
|
||||
['employee_code', 'Employee Code'],
|
||||
['position_name', 'Position Name'],
|
||||
];
|
||||
|
||||
function emptyDesigner() {
|
||||
return { fields: [] };
|
||||
}
|
||||
|
||||
function asDesigner(raw) {
|
||||
let value = raw;
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
try {
|
||||
value = JSON.parse(raw);
|
||||
} catch {
|
||||
value = null;
|
||||
}
|
||||
}
|
||||
const fields = Array.isArray(value?.fields) ? value.fields : [];
|
||||
return {
|
||||
fields: fields
|
||||
.filter((row) => row && row.id && row.source && row.key)
|
||||
.map((row) => ({
|
||||
id: String(row.id),
|
||||
source: String(row.source),
|
||||
key: String(row.key),
|
||||
label: row.label || String(row.key),
|
||||
level: row.level || undefined,
|
||||
show_in_export: row.show_in_export !== false,
|
||||
show_in_view: Boolean(row.show_in_view),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeDataDesigner(raw) {
|
||||
return asDesigner(raw);
|
||||
}
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
user: 'User attributes',
|
||||
hierarchy: 'Hierarchy',
|
||||
manager: 'Manager info',
|
||||
question: 'Form questions',
|
||||
manual: 'Manual',
|
||||
};
|
||||
|
||||
function managerStoredId(level, key) {
|
||||
const number = String(level || '').replace('l', '');
|
||||
const suffix = key === 'employee_code' ? 'code' : key === 'position_name' ? 'position' : key;
|
||||
return `manager${number}_${suffix}`;
|
||||
}
|
||||
|
||||
function option(id, source, key, label, extra = {}) {
|
||||
return { value: id, label, row: { id, source, key, label, show_in_export: true, show_in_view: false, ...extra } };
|
||||
}
|
||||
|
||||
function CheckDropdown({ title, count, options, taken, onToggle, emptyText, disabled, disabledText }) {
|
||||
return (
|
||||
<Dropdown autoClose="outside">
|
||||
<Dropdown.Toggle variant="outline-dark" disabled={disabled}>
|
||||
{disabled ? disabledText : `${title} (${count})`}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu className="p-2 border shadow-sm" style={{ maxHeight: 280, overflowY: 'auto' }}>
|
||||
{options.length ? options.map((item) => (
|
||||
<Dropdown.Item key={item.value} as="div" className="px-1">
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
label={item.label}
|
||||
checked={taken.has(item.value)}
|
||||
onChange={(event) => onToggle(item, event.target.checked)}
|
||||
/>
|
||||
</Dropdown.Item>
|
||||
)) : <div className="text-muted small px-2">{emptyText}</div>}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function DesignerRow({ row, onPatch, onRemove }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: row.id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.6 : 1,
|
||||
};
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="d-flex align-items-center gap-2 border rounded px-2 py-2 mb-2 bg-white">
|
||||
<button type="button" className="btn btn-light btn-sm" aria-label="Reorder" {...attributes} {...listeners}>
|
||||
::
|
||||
</button>
|
||||
<div className="flex-grow-1">
|
||||
<div className="fw-semibold">{row.label}</div>
|
||||
<div className="text-muted small">{SOURCE_LABELS[row.source] || row.source}</div>
|
||||
</div>
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
label="Form Data Export"
|
||||
checked={row.show_in_export !== false}
|
||||
onChange={(event) => onPatch(row.id, { show_in_export: event.target.checked })}
|
||||
/>
|
||||
<Form.Check
|
||||
type="checkbox"
|
||||
label="Form Data View"
|
||||
checked={Boolean(row.show_in_view)}
|
||||
onChange={(event) => onPatch(row.id, { show_in_view: event.target.checked })}
|
||||
/>
|
||||
<Button type="button" variant="outline-danger" size="sm" onClick={() => onRemove(row.id)}>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DataDesignerEditor({ value, onChange, userAttributes = {} }) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const formId = searchParams.get('id') || '';
|
||||
const [open, setOpen] = useState(false);
|
||||
const [questions, setQuestions] = useState([]);
|
||||
const [hierarchyLevels, setHierarchyLevels] = useState([]);
|
||||
const [manualOpen, setManualOpen] = useState(false);
|
||||
const [manualKey, setManualKey] = useState('');
|
||||
const [manualLabel, setManualLabel] = useState('');
|
||||
const designer = asDesigner(value);
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
|
||||
|
||||
useEffect(() => {
|
||||
if (!formId) {
|
||||
setQuestions([]);
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
signedGet('form', 'formquestion/get_all', { entity_id: formId }).then((response) => {
|
||||
if (cancelled || !isSuccess(response)) return;
|
||||
const items = response.data?.items || [];
|
||||
setQuestions(Array.isArray(items) ? items : Object.values(items));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [formId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
signedGet('ums', 'domain/get_one').then((response) => {
|
||||
if (cancelled) return;
|
||||
const types = isSuccess(response) ? response.data?.item?.territory_types : [];
|
||||
const levels = Array.isArray(types) ? types.map((type) => String(type || '').trim()).filter(Boolean) : [];
|
||||
setHierarchyLevels(levels.length ? levels : DEFAULT_HIERARCHY_LEVELS);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const taken = useMemo(() => new Set(designer.fields.map((row) => row.id)), [designer.fields]);
|
||||
|
||||
const userOptions = useMemo(() => {
|
||||
const fixed = [
|
||||
['name', 'Name'],
|
||||
['email', 'Email'],
|
||||
['employee_code', 'Employee Code'],
|
||||
];
|
||||
const attributes = Object.entries(userAttributes);
|
||||
const labelKey = (label) => String(label || '').trim().toLowerCase();
|
||||
const usedCodes = new Set();
|
||||
const usedLabels = new Set();
|
||||
const rows = [];
|
||||
const addRow = (key, label) => {
|
||||
const normalized = labelKey(label);
|
||||
if (!key || usedCodes.has(key) || usedLabels.has(normalized)) return;
|
||||
usedCodes.add(key);
|
||||
usedLabels.add(normalized);
|
||||
rows.push(option(key, 'user', key, label));
|
||||
};
|
||||
fixed.forEach(([key, label]) => {
|
||||
const match = attributes.find(([code, attrLabel]) => code === key || labelKey(attrLabel) === labelKey(label));
|
||||
if (match) addRow(match[0], match[1]);
|
||||
else addRow(key, label);
|
||||
});
|
||||
attributes.forEach(([code, label]) => addRow(code, label));
|
||||
return rows;
|
||||
}, [userAttributes]);
|
||||
|
||||
const hierarchyOptions = hierarchyLevels.map((level) => option(String(level).trim().toLowerCase(), 'hierarchy', level, level));
|
||||
|
||||
const managerOptions = MANAGER_LEVELS.flatMap(([level, title]) =>
|
||||
MANAGER_KEYS.map(([key, name]) =>
|
||||
option(managerStoredId(level, key), 'manager', key, `${title} Manager ${name}`, { level }),
|
||||
),
|
||||
);
|
||||
|
||||
const questionOptions = [
|
||||
...questions
|
||||
.filter((question) => question?.id && question.status !== 'disabled')
|
||||
.map((question) =>
|
||||
option(question.code || String(question.id), 'question', question.code || String(question.id), question.question || question.code || question.id),
|
||||
),
|
||||
option('submission_id', 'question', 'submission_id', 'Submission ID'),
|
||||
option('rec_date', 'question', 'rec_date', 'Record Date'),
|
||||
option('created_date', 'question', 'created_date', 'Submitted Date'),
|
||||
option('updated_date', 'question', 'updated_date', 'Last Updated Date'),
|
||||
option('workflow_status', 'question', 'workflow_status', 'Workflow Status'),
|
||||
];
|
||||
|
||||
const appendRows = (picked) => {
|
||||
const rows = (picked || []).map((item) => item.row).filter((row) => row && !taken.has(row.id));
|
||||
if (!rows.length) return;
|
||||
onChange({ fields: [...designer.fields, ...rows] });
|
||||
};
|
||||
|
||||
const patchRow = (id, patch) => {
|
||||
onChange({
|
||||
fields: designer.fields.map((row) => (row.id === id ? { ...row, ...patch } : row)),
|
||||
});
|
||||
};
|
||||
|
||||
const removeRow = (id) => {
|
||||
onChange({ fields: designer.fields.filter((row) => row.id !== id) });
|
||||
};
|
||||
|
||||
const onDragEnd = (event) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const from = designer.fields.findIndex((row) => row.id === active.id);
|
||||
const to = designer.fields.findIndex((row) => row.id === over.id);
|
||||
if (from < 0 || to < 0) return;
|
||||
onChange({ fields: arrayMove(designer.fields, from, to) });
|
||||
};
|
||||
|
||||
const toggleOption = (item, checked) => {
|
||||
if (checked) appendRows([item]);
|
||||
else removeRow(item.value);
|
||||
};
|
||||
|
||||
const countFor = (source) => designer.fields.filter((row) => row.source === source).length;
|
||||
|
||||
const addManual = () => {
|
||||
const key = manualKey.trim();
|
||||
const label = manualLabel.trim();
|
||||
if (!key || !label) return;
|
||||
const id = key;
|
||||
if (taken.has(id)) return;
|
||||
onChange({
|
||||
fields: [...designer.fields, { id, source: 'manual', key, label, show_in_export: true, show_in_view: false }],
|
||||
});
|
||||
setManualKey('');
|
||||
setManualLabel('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded p-3">
|
||||
<div className="d-flex justify-content-between align-items-center mb-2">
|
||||
<strong>Data Designer</strong>
|
||||
<Button type="button" variant="dark" size="sm" onClick={() => setOpen((current) => !current)}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{open ? (
|
||||
<div className="mb-3">
|
||||
<div className="d-flex flex-wrap gap-2 align-items-start mb-3">
|
||||
<CheckDropdown title="User attributes" count={countFor('user')} options={userOptions} taken={taken} onToggle={toggleOption} emptyText="No user fields." />
|
||||
<CheckDropdown title="Hierarchy" count={countFor('hierarchy')} options={hierarchyOptions} taken={taken} onToggle={toggleOption} emptyText="No hierarchy levels." />
|
||||
<CheckDropdown title="Manager info" count={countFor('manager')} options={managerOptions} taken={taken} onToggle={toggleOption} emptyText="No manager fields." />
|
||||
<CheckDropdown
|
||||
title="Form questions"
|
||||
count={countFor('question')}
|
||||
options={questionOptions}
|
||||
taken={taken}
|
||||
onToggle={toggleOption}
|
||||
emptyText="No questions."
|
||||
disabled={!formId}
|
||||
disabledText="Save the form before choosing questions"
|
||||
/>
|
||||
<Button type="button" variant={manualOpen ? 'dark' : 'outline-dark'} onClick={() => setManualOpen((current) => !current)}>
|
||||
Manual ({countFor('manual')})
|
||||
</Button>
|
||||
</div>
|
||||
{manualOpen ? (
|
||||
<div className="d-flex flex-wrap gap-2 align-items-end">
|
||||
<Form.Group>
|
||||
<Form.Label>Field name</Form.Label>
|
||||
<Form.Control value={manualKey} onChange={(event) => setManualKey(event.target.value)} placeholder="erdostyne_capsule_value" />
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Form.Label>Column title</Form.Label>
|
||||
<Form.Control value={manualLabel} onChange={(event) => setManualLabel(event.target.value)} placeholder="ERDOSTYNE CAPSULE 10s Value" />
|
||||
</Form.Group>
|
||||
<Button type="button" variant="outline-dark" onClick={addManual}>
|
||||
Add manual field
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={designer.fields.map((row) => row.id)} strategy={verticalListSortingStrategy}>
|
||||
{designer.fields.map((row) => (
|
||||
<DesignerRow key={row.id} row={row} onPatch={patchRow} onRemove={removeRow} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
{!designer.fields.length ? <p className="text-muted mb-0">No fields selected.</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataDesignerField({ field, control }) {
|
||||
return (
|
||||
<Controller
|
||||
name={field.key}
|
||||
control={control}
|
||||
render={({ field: rhf }) => (
|
||||
<DataDesignerEditor value={rhf.value} onChange={rhf.onChange} userAttributes={field.userAttributes || {}} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,33 +1,44 @@
|
|||
import { Controller } from 'react-hook-form';
|
||||
import { useEffect } from 'react';
|
||||
import { useController } from 'react-hook-form';
|
||||
import { Form } from 'react-bootstrap';
|
||||
|
||||
export default function DependentSelectField({ field, control, watch }) {
|
||||
const parentValue = watch(field.dependsOn);
|
||||
const staticOptions = field.staticOptions || {};
|
||||
const questions = (field.questions || []).filter((question) => question.entity_id === parentValue);
|
||||
const options = { ...staticOptions };
|
||||
questions.forEach((question) => {
|
||||
if (question.code) options[question.code] = `${question.question || question.code} (Only Experts)`;
|
||||
const staticOptions = Object.entries(field.staticOptions || {}).map(([value, label]) => ({ value, label }));
|
||||
const allowedTypes = field.questionTypes || (field.questionType ? [field.questionType] : null);
|
||||
const questions = (field.questions || []).filter((question) => {
|
||||
const questionFormId = question.entity_id ?? question.form_id;
|
||||
if (String(questionFormId ?? '') !== String(parentValue || '')) return false;
|
||||
if (!allowedTypes) return true;
|
||||
return allowedTypes.includes(String(question.type || '').toLowerCase());
|
||||
});
|
||||
const options = [
|
||||
...staticOptions,
|
||||
...questions
|
||||
.filter((question) => question.code)
|
||||
.map((question) => ({
|
||||
value: question.code,
|
||||
label: `${question.question || question.code} (Only Experts)`,
|
||||
})),
|
||||
];
|
||||
const optionValues = new Set(options.map((option) => String(option.value)));
|
||||
const fallback = staticOptions[0]?.value || '';
|
||||
const { field: rhf } = useController({ name: field.key, control });
|
||||
const selected = optionValues.has(String(rhf.value || '')) ? rhf.value : fallback;
|
||||
|
||||
useEffect(() => {
|
||||
if (String(rhf.value || '') === String(selected || '')) return;
|
||||
rhf.onChange(selected);
|
||||
}, [parentValue, selected, rhf.value, rhf.onChange]);
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={field.key}
|
||||
control={control}
|
||||
render={({ field: rhf }) => (
|
||||
<Form.Select
|
||||
required={field.required}
|
||||
value={rhf.value || ''}
|
||||
onChange={(e) => rhf.onChange(e.target.value)}
|
||||
>
|
||||
<option value=""> -- Select {field.title} -- </option>
|
||||
{Object.entries(options).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
)}
|
||||
/>
|
||||
<Form.Select required={field.required} value={selected || ''} onChange={(e) => rhf.onChange(e.target.value)}>
|
||||
{staticOptions.length ? null : <option value=""> -- Select {field.title} -- </option>}
|
||||
{options.map((option, index) => (
|
||||
<option key={`${option.value}-${index}`} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ function emptyRow() {
|
|||
checksubmission_days: '',
|
||||
message: '',
|
||||
plan_date_field: '',
|
||||
notification_type: 'normal',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -165,6 +166,17 @@ export default function ReminderRowsField({ field, control }) {
|
|||
onChange={(e) => rhf.onChange(patchRow(rows, index, { plan_date_field: e.target.value }))}
|
||||
/>
|
||||
</Col>
|
||||
<Col md={2}>
|
||||
<Form.Label>Notification Type</Form.Label>
|
||||
<Form.Select
|
||||
required
|
||||
value={row.notification_type || 'normal'}
|
||||
onChange={(e) => rhf.onChange(patchRow(rows, index, { notification_type: e.target.value }))}
|
||||
>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="overlay_custom">Overlay Custom</option>
|
||||
</Form.Select>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import { Button, Col, Form, Row } from 'react-bootstrap';
|
||||
import { Controller } from 'react-hook-form';
|
||||
import S3FileField from '@/components/S3FileField';
|
||||
|
||||
const YES_NO = [
|
||||
{ value: 'no', label: 'No' },
|
||||
{ value: 'yes', label: 'Yes' },
|
||||
];
|
||||
|
||||
function emptyRow() {
|
||||
return {
|
||||
template: '',
|
||||
createpdf: 'no',
|
||||
addlink: 'no',
|
||||
linkfield: '',
|
||||
positions: '',
|
||||
};
|
||||
}
|
||||
|
||||
function patchRow(rows, index, patch) {
|
||||
return rows.map((row, i) => (i === index ? { ...row, ...patch } : row));
|
||||
}
|
||||
|
||||
export default function TemplatePositionsField({ field, control, moduleName, onUploadingChange }) {
|
||||
const linkFields = field.values || field.linkFields || {};
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={field.key}
|
||||
control={control}
|
||||
render={({ field: rhf }) => {
|
||||
const rows = Array.isArray(rhf.value) ? rhf.value : [];
|
||||
return (
|
||||
<div>
|
||||
<div className="d-flex justify-content-end mb-2">
|
||||
<Button
|
||||
variant="info"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => rhf.onChange([...rows, emptyRow()])}
|
||||
>
|
||||
Add New Template
|
||||
</Button>
|
||||
</div>
|
||||
{rows.length === 0 ? <div className="text-muted fs-12">No templates</div> : null}
|
||||
{rows.map((row, index) => (
|
||||
<div key={index} className="border rounded p-3 mb-3">
|
||||
<Row className="g-3">
|
||||
<Col md={8}>
|
||||
<S3FileField
|
||||
field={{ type: 'image', allowedExtensions: ['png', 'jpg', 'jpeg'], uploadModule: 'personalization' }}
|
||||
value={row.template || ''}
|
||||
onChange={(url) => rhf.onChange(patchRow(rows, index, { template: url }))}
|
||||
moduleName={moduleName}
|
||||
onUploadingChange={onUploadingChange}
|
||||
/>
|
||||
</Col>
|
||||
<Col md={3}>
|
||||
<Form.Label>Include in PDF</Form.Label>
|
||||
<Form.Select
|
||||
className="mb-2"
|
||||
value={row.createpdf || 'no'}
|
||||
onChange={(e) => rhf.onChange(patchRow(rows, index, { createpdf: e.target.value }))}
|
||||
>
|
||||
{YES_NO.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
<Form.Label>Add Link to PDF</Form.Label>
|
||||
<Form.Select
|
||||
className="mb-2"
|
||||
value={row.addlink || 'no'}
|
||||
onChange={(e) => rhf.onChange(patchRow(rows, index, { addlink: e.target.value }))}
|
||||
>
|
||||
{YES_NO.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
<Form.Label>Link Field</Form.Label>
|
||||
<Form.Select
|
||||
value={row.linkfield || ''}
|
||||
onChange={(e) => rhf.onChange(patchRow(rows, index, { linkfield: e.target.value }))}
|
||||
>
|
||||
<option value=""> -- Select Link Field -- </option>
|
||||
{Object.entries(linkFields).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col md={1} className="d-flex align-items-start pt-1">
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => rhf.onChange(rows.filter((_, i) => i !== index))}
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</Col>
|
||||
<Col md={12}>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={4}
|
||||
placeholder="Enter Position"
|
||||
value={row.positions || ''}
|
||||
onChange={(e) => rhf.onChange(patchRow(rows, index, { positions: e.target.value }))}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -89,7 +89,7 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
|
|||
icon: LuUsers,
|
||||
children: [
|
||||
child('contact-list', 'Contacts', '/contacts', 'contact_get_all'),
|
||||
child('contact-export', 'Contact Export', '/contactexports', 'contact_get_all'),
|
||||
child('contact-export', 'Contact Export', '/contact/export', 'contact_get_all'),
|
||||
child('contact-attrs', 'Attributes', '/contactattributes', 'contactattribute_get_all'),
|
||||
child('contact-lists', 'Contact Lists', '/contactlists', 'contact_list_get_all'),
|
||||
].filter((item) => !item.permission || permissions.includes(item.permission)),
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ const contactexports = {
|
|||
module: 'contact_exports',
|
||||
title: 'Contact Exports',
|
||||
subtitle: 'Contact Management',
|
||||
baseUrl: '/contactexport',
|
||||
listPath: '/contactexports',
|
||||
baseUrl: '/contact',
|
||||
listPath: '/contact/export',
|
||||
addPath: '/contact/exportcreate',
|
||||
skipEdit: true,
|
||||
deleteMethod: 'post',
|
||||
afterSaveMessage: 'Export request created. Refresh the list in a few moments to download the file when ready.',
|
||||
|
|
@ -30,7 +31,7 @@ const contactexports = {
|
|||
{ key: 'createdAt', title: 'Created Date', type: 'datetime' },
|
||||
],
|
||||
listButtons: [
|
||||
{ title: 'Add New', to: '/contactexport/addnew' },
|
||||
{ title: 'Create Export', to: '/contact/exportcreate' },
|
||||
{ title: 'Refresh', action: 'reload' },
|
||||
],
|
||||
mapItems: (items) =>
|
||||
|
|
@ -45,6 +46,8 @@ const contactexports = {
|
|||
(item) => `${item.name} (${item.code})`,
|
||||
(item) => item.code,
|
||||
);
|
||||
const positionValues = { ...positions };
|
||||
delete positionValues.all;
|
||||
return [
|
||||
{
|
||||
key: 'position_code',
|
||||
|
|
@ -53,14 +56,14 @@ const contactexports = {
|
|||
required: true,
|
||||
exclusiveValue: 'all',
|
||||
fullwidth: true,
|
||||
values: { all: 'All positions', ...positions },
|
||||
values: { all: 'All Position Code', ...positionValues },
|
||||
},
|
||||
{
|
||||
key: 'export_status',
|
||||
title: 'Contact Status',
|
||||
type: 'select',
|
||||
required: true,
|
||||
values: { active: 'Active', disabled: 'Disabled' },
|
||||
values: { active: 'Active', disabled: 'Disabled', deleted: 'Deleted' },
|
||||
},
|
||||
];
|
||||
},
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const contacts = {
|
|||
listButtons: [
|
||||
{ title: 'Add New', to: '/contact/addnew' },
|
||||
{ title: 'Import Contacts', to: '/contact/import' },
|
||||
{ title: 'Export', to: '/contactexports' },
|
||||
{ title: 'Export', to: '/contact/export' },
|
||||
],
|
||||
formFields: async () => {
|
||||
const [attributesRes, positions] = await Promise.all([
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { signedGet, signedPost, isSuccess } from '@/api/client';
|
||||
import { dropdownMap } from '@/api/dropdowns';
|
||||
import { normalizeDataDesigner } from '@/components/form/DataDesignerField';
|
||||
import { dropdownMap, fetchUserAttributes } from '@/api/dropdowns';
|
||||
import { splitHash } from '@/helpers/metric';
|
||||
|
||||
const STATUS = { active: 'Active', disabled: 'Disabled' };
|
||||
|
|
@ -56,11 +57,11 @@ const formentitys = {
|
|||
{ key: 'status', title: 'Status', type: 'select' },
|
||||
],
|
||||
formFields: async () => {
|
||||
const [usersRes, roles, contactRes, userAttrRes, workflowRes] = await Promise.all([
|
||||
const [usersRes, roles, contactRes, userAttrItems, workflowRes] = await Promise.all([
|
||||
signedGet('ums', 'users/dropdown'),
|
||||
dropdownMap('ums', 'roles', (item) => item.name),
|
||||
signedGet('contact', 'contactattribute/get_all'),
|
||||
signedGet('ums', 'userattribute/get_all'),
|
||||
fetchUserAttributes(),
|
||||
signedGet('workflow', 'workflow_definition/get_all'),
|
||||
]);
|
||||
const approvers = {
|
||||
|
|
@ -83,7 +84,6 @@ const formentitys = {
|
|||
if (attr?.code) contactAttributes[attr.code] = attr.attribute || attr.code;
|
||||
});
|
||||
const userAttributes = {};
|
||||
const userAttrItems = isSuccess(userAttrRes) ? userAttrRes.data?.items || [] : [];
|
||||
userAttrItems.forEach((attr) => {
|
||||
if (!attr?.code || attr.status === 'disabled') return;
|
||||
userAttributes[attr.code] = attr.attribute || attr.code;
|
||||
|
|
@ -122,7 +122,6 @@ const formentitys = {
|
|||
type: 'multiselect',
|
||||
values: userAttributes,
|
||||
default: [],
|
||||
showIf: { show_user_attributes: 'yes' },
|
||||
},
|
||||
{
|
||||
key: 'form_type',
|
||||
|
|
@ -203,6 +202,13 @@ const formentitys = {
|
|||
roles,
|
||||
users: notifyUsers,
|
||||
},
|
||||
{
|
||||
key: 'data_designer',
|
||||
type: 'data_designer',
|
||||
fullwidth: true,
|
||||
hide_label: true,
|
||||
userAttributes,
|
||||
},
|
||||
];
|
||||
},
|
||||
beforeForm: (item) => {
|
||||
|
|
@ -234,10 +240,12 @@ const formentitys = {
|
|||
next.contact_attributes = asArray(next.contact_attributes);
|
||||
next.show_user_attributes = yesno(next.show_user_attributes, 'no');
|
||||
next.user_attributes = asArray(next.user_attributes);
|
||||
if (next.user_attributes.length) next.show_user_attributes = 'yes';
|
||||
next.roles_email_notification = asArray(next.roles_email_notification);
|
||||
next.roles_push_notification = asArray(next.roles_push_notification);
|
||||
next.workflow_approvers = Array.isArray(next.workflow_approvers) && next.workflow_approvers.length ? next.workflow_approvers : ['l1manager'];
|
||||
next.workflow_definition_id = next.workflow_definition_id || '';
|
||||
next.data_designer = normalizeDataDesigner(next.data_designer);
|
||||
return next;
|
||||
},
|
||||
beforeSave: async (data, mode) => {
|
||||
|
|
@ -246,8 +254,10 @@ const formentitys = {
|
|||
payload.export_manager_levels = asArray(payload.export_manager_levels);
|
||||
payload.manager_de_roles = asArray(payload.manager_de_roles);
|
||||
payload.contact_attributes = asArray(payload.contact_attributes);
|
||||
payload.data_designer = normalizeDataDesigner(payload.data_designer);
|
||||
payload.show_user_attributes = payload.show_user_attributes === 'yes' ? 'yes' : 'no';
|
||||
payload.user_attributes = asArray(payload.user_attributes);
|
||||
if (payload.user_attributes.length) payload.show_user_attributes = 'yes';
|
||||
payload.enrollment_form_attributes = asArray(payload.enrollment_form_attributes);
|
||||
payload.roles_email_notification = asArray(payload.roles_email_notification);
|
||||
payload.roles_push_notification = asArray(payload.roles_push_notification);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ const formexports = {
|
|||
{ title: 'Add New', to: '/formexport/addnew' },
|
||||
{ title: 'Refresh', action: 'reload' },
|
||||
],
|
||||
mapItems: (items) =>
|
||||
[...(items || [])].sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)),
|
||||
formFields: async () => {
|
||||
const [forms, dropdown] = await Promise.all([
|
||||
dropdownMap('form', 'formentity', (item) => item.title),
|
||||
|
|
@ -44,6 +46,8 @@ const formexports = {
|
|||
required: true,
|
||||
dependsOn: 'form_id',
|
||||
questions,
|
||||
questionType: 'date',
|
||||
default: 'record_date',
|
||||
staticOptions: { record_date: 'Default Date (Recommended)' },
|
||||
},
|
||||
{ key: 'start_date', title: 'Start Date', type: 'date', required: true },
|
||||
|
|
|
|||
|
|
@ -3,6 +3,43 @@ import { splitHash } from '@/helpers/metric';
|
|||
|
||||
const STATUS = { active: 'Active', disabled: 'Disabled' };
|
||||
|
||||
function asList(value) {
|
||||
if (value == null || value === '') return [];
|
||||
if (Array.isArray(value)) return value;
|
||||
if (typeof value === 'object') return Object.values(value);
|
||||
return [value];
|
||||
}
|
||||
|
||||
function stringifyPositions(value) {
|
||||
if (value == null || value === '') return '';
|
||||
if (typeof value === 'string') return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function flattenMappings(item) {
|
||||
const templates = asList(item?.templates);
|
||||
const positions = asList(item?.positions);
|
||||
const createpdf = asList(item?.createpdf);
|
||||
const addlink = asList(item?.addlink);
|
||||
const linkfield = asList(item?.linkfield);
|
||||
const length = Math.max(templates.length, positions.length, createpdf.length, addlink.length, linkfield.length);
|
||||
const rows = [];
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
rows.push({
|
||||
template: templates[index] || '',
|
||||
createpdf: createpdf[index] === 'yes' ? 'yes' : 'no',
|
||||
addlink: addlink[index] === 'yes' ? 'yes' : 'no',
|
||||
linkfield: linkfield[index] || '',
|
||||
positions: stringifyPositions(positions[index]),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const formpersonalizations = {
|
||||
service: 'form',
|
||||
module: 'personalization',
|
||||
|
|
@ -26,24 +63,55 @@ const formpersonalizations = {
|
|||
formFields: async ({ item } = {}) => {
|
||||
const entityId = item?.entity_id || '';
|
||||
const questions = {};
|
||||
const linkFields = {};
|
||||
if (entityId) {
|
||||
const response = await signedGet('form', 'formquestion/get_all', { entity_id: entityId });
|
||||
(isSuccess(response) ? response.data?.items || [] : []).forEach((q) => {
|
||||
if (q?.id) questions[q.id] = q.question || q.title || q.id;
|
||||
(isSuccess(response) ? response.data?.items || [] : []).forEach((question) => {
|
||||
if (question?.id) questions[question.id] = question.question || question.title || question.id;
|
||||
if (question?.code) linkFields[question.code] = question.question || question.title || question.code;
|
||||
});
|
||||
}
|
||||
return [
|
||||
{ key: 'title', title: 'Title', type: 'text', required: true, fullwidth: true },
|
||||
{ key: 'questions', title: 'Select 1 or Multiple questions (to be included in this personalization)', type: 'checkbox', values: questions, required: true },
|
||||
{
|
||||
key: 'questions',
|
||||
title: 'Select 1 or Multiple questions (to be included in this personalization)',
|
||||
type: 'checkbox',
|
||||
values: questions,
|
||||
required: true,
|
||||
},
|
||||
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
|
||||
{ key: 'icon', title: 'Icon', type: 'image', required: true },
|
||||
{
|
||||
key: 'mappings',
|
||||
title: 'Template and Positions',
|
||||
type: 'template_positions',
|
||||
fullwidth: true,
|
||||
values: linkFields,
|
||||
},
|
||||
];
|
||||
},
|
||||
beforeForm: (item, _mode, ctx) => ({
|
||||
...item,
|
||||
entity_id: item.entity_id || ctx?.searchParams?.get('id') || '',
|
||||
questions: splitHash(item.questions),
|
||||
}),
|
||||
beforeForm: (item, _mode, ctx) => {
|
||||
const next = {
|
||||
...item,
|
||||
entity_id: item.entity_id || ctx?.searchParams?.get('id') || '',
|
||||
questions: splitHash(item.questions),
|
||||
};
|
||||
next.mappings = flattenMappings(next);
|
||||
return next;
|
||||
},
|
||||
beforeSave: (data) => {
|
||||
const mappings = Array.isArray(data.mappings) ? data.mappings : [];
|
||||
const payload = { ...data };
|
||||
payload.questions = Array.isArray(payload.questions) ? payload.questions.filter(Boolean) : [];
|
||||
payload.templates = mappings.map((row) => row.template || '');
|
||||
payload.positions = mappings.map((row) => row.positions || '');
|
||||
payload.createpdf = mappings.map((row) => (row.createpdf === 'yes' ? 'yes' : 'no'));
|
||||
payload.addlink = mappings.map((row) => (row.addlink === 'yes' ? 'yes' : 'no'));
|
||||
payload.linkfield = mappings.map((row) => row.linkfield || '');
|
||||
delete payload.mappings;
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
|
||||
export default formpersonalizations;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const FORM_QUESTION_TYPES = {
|
|||
};
|
||||
|
||||
const STATUS = { active: 'Active', disabled: 'Disabled' };
|
||||
const OPTION_TYPES = ['dropdown', 'checkbox', 'radio', 'radiometrix', 'multitextbox'];
|
||||
|
||||
const formquestions = {
|
||||
service: 'form',
|
||||
|
|
@ -49,7 +50,7 @@ const formquestions = {
|
|||
{ key: 'required', title: 'Is Required', type: 'yesno', required: true },
|
||||
{ key: 'sort_order', title: 'Sort Order', type: 'number' },
|
||||
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
|
||||
{ key: 'options', title: 'Options', type: 'mapping', variant: 'question_options', fullwidth: true },
|
||||
{ key: 'options', title: 'Options', type: 'mapping', variant: 'question_options', fullwidth: true, showIf: { type: OPTION_TYPES } },
|
||||
],
|
||||
beforeForm: (item) => ({
|
||||
...item,
|
||||
|
|
@ -59,7 +60,7 @@ const formquestions = {
|
|||
beforeSave: (data) => ({
|
||||
...data,
|
||||
sort_order: data.sort_order === '' || data.sort_order == null ? 0 : Number(data.sort_order),
|
||||
options: Array.isArray(data.options) ? data.options : [],
|
||||
options: OPTION_TYPES.includes(data.type) && Array.isArray(data.options) ? data.options : [],
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { signedGet, isSuccess } from '@/api/client';
|
||||
import { dropdownMap } from '@/api/dropdowns';
|
||||
import { dropdownMap, fetchUserAttributes } from '@/api/dropdowns';
|
||||
import {
|
||||
DEFAULT_INTERVAL_VALUES,
|
||||
INTERVAL_VALUES,
|
||||
metricOptions,
|
||||
parseCombinedMetrics,
|
||||
splitHash,
|
||||
stringifyCombinedMetrics,
|
||||
toDateInput,
|
||||
} from '@/helpers/metric';
|
||||
|
||||
|
|
@ -17,12 +17,57 @@ const MANAGER_LEVELS = {
|
|||
l3manager: 'L3 Manager',
|
||||
l4manager: 'L4 Manager',
|
||||
};
|
||||
const DEFAULT_INTERVAL_VALUES = {
|
||||
'': 'Overall / All',
|
||||
all: 'Overall',
|
||||
'month-0': 'This Month',
|
||||
'month-1': 'Last Month',
|
||||
'month-3': 'Last 3 Months',
|
||||
'month-6': 'Last 6 Months',
|
||||
'month-12': 'Last 12 Months',
|
||||
'day-0': 'Today',
|
||||
'day-1': 'Yesterday',
|
||||
'day-7': 'Last 7 Days',
|
||||
'day-30': 'Last 30 Days',
|
||||
'week-0': 'This week',
|
||||
'week-1': 'Last week',
|
||||
'yearfy-0': 'This Financial Year',
|
||||
'yearfy-1': 'Last Financial Year',
|
||||
'yearcy-0': 'This Calender Year',
|
||||
'yearcy-1': 'Last Calender Year',
|
||||
};
|
||||
|
||||
function asArray(value) {
|
||||
if (Array.isArray(value)) return value.filter((part) => part !== null && part !== undefined && part !== '');
|
||||
return splitHash(value);
|
||||
}
|
||||
|
||||
function bySortOrder(left, right) {
|
||||
return (Number(left?.sort_order) || 0) - (Number(right?.sort_order) || 0);
|
||||
}
|
||||
|
||||
function itemsToMap(items) {
|
||||
const values = {};
|
||||
(items || []).forEach((item) => {
|
||||
const id = item?.id;
|
||||
if (id === undefined || id === null || id === '') return;
|
||||
values[id] = item.name || item.title || id;
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
async function teamOptions() {
|
||||
const fromDropdown = await dropdownMap('detailing', 'team', (item) => item.name);
|
||||
if (Object.keys(fromDropdown).length) return fromDropdown;
|
||||
for (const path of ['team/get_all', 'team/list']) {
|
||||
const response = await signedGet('detailing', path);
|
||||
const items = isSuccess(response) ? response.data?.items || response.data || [] : [];
|
||||
const values = itemsToMap(Array.isArray(items) ? items : []);
|
||||
if (Object.keys(values).length) return values;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const leaderboards = {
|
||||
service: 'reports',
|
||||
module: 'roles_leaderboard',
|
||||
|
|
@ -33,6 +78,7 @@ const leaderboards = {
|
|||
listFields: [
|
||||
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete', { name: 'report', to: (row) => `/leaderboard/dashboard?id=${row.id}`, title: 'Dashboard' }] },
|
||||
{ key: 'name', title: 'Name', type: 'text' },
|
||||
{ key: 'leaderboard_roles_type_label', title: 'Type', type: 'text' },
|
||||
{ key: 'sort_order', title: 'Sort Order', type: 'number' },
|
||||
{ key: 'visible_roles_label', title: 'Visible Roles', type: 'text' },
|
||||
{ key: 'validity_start', title: 'Validity start', type: 'text' },
|
||||
|
|
@ -43,16 +89,17 @@ const leaderboards = {
|
|||
(items || []).map((item) => ({
|
||||
...item,
|
||||
visible_roles_label: asArray(item.visible_roles).join(', '),
|
||||
leaderboard_roles_type_label: item.leaderboard_roles_type === 'team' ? 'Team' : 'Roles',
|
||||
sort_order: item.sort_order ?? 0,
|
||||
})),
|
||||
formFields: async () => {
|
||||
const [metrics, roles, userAttrRes] = await Promise.all([
|
||||
const [metrics, roles, teams, userAttrItems] = await Promise.all([
|
||||
metricOptions(),
|
||||
dropdownMap('ums', 'roles', (item) => item.name),
|
||||
signedGet('ums', 'userattribute/get_all'),
|
||||
teamOptions(),
|
||||
fetchUserAttributes(),
|
||||
]);
|
||||
const userAttributes = {};
|
||||
const userAttrItems = isSuccess(userAttrRes) ? userAttrRes.data?.items || [] : [];
|
||||
userAttrItems.forEach((attr) => {
|
||||
if (!attr?.code || attr.status === 'disabled') return;
|
||||
userAttributes[attr.code] = attr.attribute || attr.code;
|
||||
|
|
@ -62,9 +109,33 @@ const leaderboards = {
|
|||
{ key: 'sort_order', title: 'Sort Order', type: 'number', required: true },
|
||||
{ key: 'validity_start', title: 'Validity start', type: 'date', required: true },
|
||||
{ key: 'validity_end', title: 'Validity end', type: 'date', required: true },
|
||||
{
|
||||
key: 'leaderboard_roles_type',
|
||||
title: 'Leaderboard Type',
|
||||
type: 'select',
|
||||
required: true,
|
||||
values: { roles: 'Roles', team: 'Team' },
|
||||
},
|
||||
{
|
||||
key: 'leaderboard_team_ids',
|
||||
title: 'Teams',
|
||||
type: 'multiselect',
|
||||
values: teams,
|
||||
fullwidth: true,
|
||||
required: true,
|
||||
showIf: { leaderboard_roles_type: 'team' },
|
||||
},
|
||||
{ key: 'visible_roles', title: 'Visible Roles', type: 'multiselect', values: roles, required: true },
|
||||
{ key: 'reporting_roles', title: 'Reporting Roles', type: 'multiselect', values: roles, required: true },
|
||||
{ key: 'rank_roles', title: 'Rank Roles', type: 'rank_roles', roles, required: true, fullwidth: true },
|
||||
{
|
||||
key: 'rank_roles',
|
||||
title: 'Rank Roles',
|
||||
type: 'rank_roles',
|
||||
roles,
|
||||
required: true,
|
||||
fullwidth: true,
|
||||
showIf: { leaderboard_roles_type: 'roles' },
|
||||
},
|
||||
{
|
||||
key: 'metrics',
|
||||
title: 'Choose Metric',
|
||||
|
|
@ -76,7 +147,7 @@ const leaderboards = {
|
|||
},
|
||||
{ key: 'combined_metrics', title: 'Combined Metrics (if any)', type: 'combined_metrics', fullwidth: true },
|
||||
{ key: 'intervals', title: 'Intervals', type: 'multiselect', required: true, values: INTERVAL_VALUES },
|
||||
{ key: 'default_interval', title: 'Default Interval', type: 'select', values: { '': 'Overall / All', all: 'Overall', ...DEFAULT_INTERVAL_VALUES } },
|
||||
{ key: 'default_interval', title: 'Default Interval', type: 'select', values: DEFAULT_INTERVAL_VALUES },
|
||||
{ key: 'show_positions', title: 'Show Positions', type: 'select', required: true, values: YES_NO },
|
||||
{
|
||||
key: 'show_positions_type',
|
||||
|
|
@ -85,8 +156,8 @@ const leaderboards = {
|
|||
required: true,
|
||||
values: { all: 'All', loggedin: 'Logged In' },
|
||||
},
|
||||
{ key: 'show_units_export', title: 'Show Units Export', type: 'select', required: true, values: YES_NO, default: 'yes' },
|
||||
{ key: 'show_user_attributes', title: 'Show User Attributes', type: 'select', required: true, values: YES_NO, default: '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',
|
||||
|
|
@ -95,52 +166,76 @@ const leaderboards = {
|
|||
default: [],
|
||||
showIf: { show_user_attributes: 'yes' },
|
||||
},
|
||||
{ key: 'show_normal_metrics', title: 'Show Normal Metrics', type: 'select', required: true, values: YES_NO, default: 'yes' },
|
||||
{ key: 'show_combined_metrics', title: 'Show Combined Metrics', type: 'select', required: true, values: YES_NO, default: 'yes' },
|
||||
{ key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS, default: ['l1manager'] },
|
||||
{ key: 'total_units_name', title: 'Total Units Name', type: 'text', default: '' },
|
||||
{ key: 'show_normal_metrics', title: 'Show Normal Metrics', type: 'select', values: YES_NO },
|
||||
{ key: 'show_combined_metrics', title: 'Show Combined Metrics', type: 'select', values: YES_NO },
|
||||
{ key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS },
|
||||
{ key: 'total_units_name', title: 'Total Units Name', type: 'text', placeholder: 'Total Units' },
|
||||
{ key: 'bg_color', title: 'Background Color', type: 'color' },
|
||||
{ key: 'bg_image', title: 'Background Image', type: 'image', uploadModule: 'leaderboard' },
|
||||
{ key: 'icon', title: 'Icon', type: 'image', uploadModule: 'leaderboard' },
|
||||
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
|
||||
];
|
||||
},
|
||||
beforeForm: (item) => {
|
||||
const next = { ...item };
|
||||
next.sort_order = next.sort_order ?? 0;
|
||||
next.show_positions = next.show_positions || 'no';
|
||||
next.show_positions = String(next.show_positions || 'no').toLowerCase();
|
||||
next.show_positions_type = next.show_positions_type || 'all';
|
||||
next.show_units_export = next.show_units_export || 'yes';
|
||||
next.show_user_attributes = next.show_user_attributes === 'yes' ? 'yes' : 'no';
|
||||
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);
|
||||
next.show_normal_metrics = next.show_normal_metrics || 'yes';
|
||||
next.show_combined_metrics = next.show_combined_metrics || 'yes';
|
||||
next.manager_details = asArray(next.manager_details);
|
||||
if (!next.manager_details.length) next.manager_details = ['l1manager'];
|
||||
if (next.user_attributes.length && next.show_user_attributes !== 'yes') {
|
||||
next.show_user_attributes = 'yes';
|
||||
}
|
||||
next.show_normal_metrics = next.show_normal_metrics === 'no' ? 'no' : 'yes';
|
||||
next.show_combined_metrics = next.show_combined_metrics === 'no' ? 'no' : 'yes';
|
||||
next.total_units_name = next.total_units_name || '';
|
||||
next.status = next.status || 'active';
|
||||
next.default_interval = next.default_interval ?? '';
|
||||
next.leaderboard_roles_type = next.leaderboard_roles_type === 'team' ? 'team' : 'roles';
|
||||
next.bg_color = next.bg_color || '';
|
||||
next.bg_image = next.bg_image || '';
|
||||
next.icon = next.icon || '';
|
||||
next.validity_start = toDateInput(next.validity_start);
|
||||
next.validity_end = toDateInput(next.validity_end);
|
||||
next.visible_roles = asArray(next.visible_roles);
|
||||
next.reporting_roles = asArray(next.reporting_roles);
|
||||
next.intervals = asArray(next.intervals);
|
||||
next.rank_roles = Array.isArray(next.rank_roles) ? next.rank_roles : [];
|
||||
next.metrics = Array.isArray(next.metrics)
|
||||
? next.metrics.map((row) => ({
|
||||
metric: row.metric || '',
|
||||
display: row.display || '',
|
||||
sort_order: row.sort_order ?? 0,
|
||||
weight: row.weight ?? 1,
|
||||
list_view: row.list_view || 'yes',
|
||||
}))
|
||||
: [];
|
||||
next.leaderboard_team_ids = asArray(next.leaderboard_team_ids);
|
||||
next.manager_details = asArray(next.manager_details);
|
||||
if (!next.manager_details.length) next.manager_details = ['l1manager'];
|
||||
next.rank_roles = (Array.isArray(next.rank_roles) ? next.rank_roles : []).slice().sort(bySortOrder);
|
||||
next.metrics = (Array.isArray(next.metrics) ? next.metrics : [])
|
||||
.map((row) => ({
|
||||
metric: row.metric || '',
|
||||
display: row.display || '',
|
||||
sort_order: row.sort_order ?? 0,
|
||||
weight: row.weight ?? 1,
|
||||
list_view: row.list_view || 'yes',
|
||||
}))
|
||||
.sort(bySortOrder);
|
||||
next.combined_metrics = parseCombinedMetrics(next.combined_metrics);
|
||||
return next;
|
||||
},
|
||||
beforeSave: (data) => {
|
||||
const payload = { ...data };
|
||||
const leaderboardType = payload.leaderboard_roles_type === 'team' ? 'team' : 'roles';
|
||||
payload.leaderboard_roles_type = leaderboardType;
|
||||
payload.sort_order = payload.sort_order === '' || payload.sort_order == null ? 0 : Number(payload.sort_order);
|
||||
payload.visible_roles = asArray(payload.visible_roles);
|
||||
payload.reporting_roles = asArray(payload.reporting_roles);
|
||||
payload.intervals = asArray(payload.intervals);
|
||||
payload.leaderboard_team_ids = leaderboardType === 'team' ? asArray(payload.leaderboard_team_ids) : [];
|
||||
payload.show_units_export = payload.show_units_export === 'no' ? 'no' : 'yes';
|
||||
payload.show_user_attributes = payload.show_user_attributes === 'yes' ? 'yes' : 'no';
|
||||
payload.user_attributes = asArray(payload.user_attributes);
|
||||
payload.show_normal_metrics = payload.show_normal_metrics === 'no' ? 'no' : 'yes';
|
||||
payload.show_combined_metrics = payload.show_combined_metrics === 'no' ? 'no' : 'yes';
|
||||
payload.total_units_name = payload.total_units_name || '';
|
||||
payload.manager_details = asArray(payload.manager_details);
|
||||
if (!payload.manager_details.length) payload.manager_details = ['l1manager'];
|
||||
payload.bg_color = Array.isArray(payload.bg_color) ? '' : payload.bg_color || '';
|
||||
payload.default_interval = payload.default_interval ?? '';
|
||||
payload.rank_roles = (Array.isArray(payload.rank_roles) ? payload.rank_roles : [])
|
||||
.filter((row) => row.title || row.role)
|
||||
.map((row) => ({
|
||||
|
|
@ -157,15 +252,11 @@ const leaderboards = {
|
|||
sort_order: row.sort_order === '' || row.sort_order == null ? 0 : Number(row.sort_order),
|
||||
weight: row.weight === '' || row.weight == null ? '1' : String(row.weight),
|
||||
}));
|
||||
payload.combined_metrics = Array.isArray(payload.combined_metrics) ? payload.combined_metrics : [];
|
||||
payload.show_units_export = payload.show_units_export || '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 || 'yes';
|
||||
payload.show_combined_metrics = payload.show_combined_metrics || 'yes';
|
||||
payload.manager_details = asArray(payload.manager_details);
|
||||
if (!payload.manager_details.length) payload.manager_details = ['l1manager'];
|
||||
payload.total_units_name = payload.total_units_name || '';
|
||||
payload.combined_metrics = stringifyCombinedMetrics(
|
||||
(Array.isArray(payload.combined_metrics) ? payload.combined_metrics : []).filter((row) => row?.type),
|
||||
);
|
||||
if (!payload.bg_image) delete payload.bg_image;
|
||||
if (!payload.icon) delete payload.icon;
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { signedGet, isSuccess } from '@/api/client';
|
||||
import { dropdownMap } from '@/api/dropdowns';
|
||||
import { dropdownMap, fetchUserAttributes } from '@/api/dropdowns';
|
||||
import {
|
||||
CAMPAIGN_COMBINED_TYPES,
|
||||
INTERVAL_VALUES,
|
||||
|
|
@ -96,15 +95,13 @@ const metriccampaigns = {
|
|||
teams_rank: item.teams_rank ? String(item.teams_rank).toLowerCase().slice(0, 2) : '',
|
||||
})),
|
||||
formFields: async () => {
|
||||
const [roles, metrics, attrsRes] = await Promise.all([
|
||||
const [roles, metrics, attrItems] = await Promise.all([
|
||||
dropdownMap('ums', 'roles', (item) => item.name),
|
||||
metricOptions(),
|
||||
signedGet('ums', 'userattribute/get_all'),
|
||||
fetchUserAttributes(),
|
||||
]);
|
||||
const attributes = {};
|
||||
const userAttributes = {};
|
||||
const attributesMeta = {};
|
||||
const attrItems = isSuccess(attrsRes) ? attrsRes.data?.items || [] : [];
|
||||
attrItems.forEach((attr) => {
|
||||
if ((attr.status || 'active') === 'disabled') return;
|
||||
const code = attr.code || '';
|
||||
|
|
@ -116,7 +113,6 @@ const metriccampaigns = {
|
|||
options[value] = String(opt.display ?? value);
|
||||
});
|
||||
attributes[code] = `${attr.attribute || code} (${code})`;
|
||||
userAttributes[code] = attr.attribute || code;
|
||||
attributesMeta[code] = {
|
||||
code,
|
||||
attribute: attr.attribute || code,
|
||||
|
|
@ -179,20 +175,20 @@ const metriccampaigns = {
|
|||
required: true,
|
||||
values: { all: 'All', loggedin: 'Logged In' },
|
||||
},
|
||||
{ key: 'show_units_export', title: 'Show Units Export', type: 'select', required: true, values: YES_NO, default: 'yes' },
|
||||
{ key: 'show_user_attributes', title: 'Show User Attributes', type: 'select', required: true, values: YES_NO, default: '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,
|
||||
values: attributes,
|
||||
default: [],
|
||||
showIf: { show_user_attributes: 'yes' },
|
||||
},
|
||||
{ key: 'show_normal_metrics', title: 'Show Normal Metrics', type: 'select', required: true, values: YES_NO, default: 'yes' },
|
||||
{ key: 'show_combined_metrics', title: 'Show Combined Metrics', type: 'select', required: true, values: YES_NO, default: 'yes' },
|
||||
{ key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS, default: ['l1manager'] },
|
||||
{ key: 'total_units_name', title: 'Total Units Name', type: 'text', default: '' },
|
||||
{ key: 'show_normal_metrics', title: 'Show Normal Metrics', type: 'select', values: YES_NO },
|
||||
{ key: 'show_combined_metrics', title: 'Show Combined Metrics', type: 'select', values: YES_NO },
|
||||
{ key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS },
|
||||
{ key: 'total_units_name', title: 'Total Units Name', type: 'text', placeholder: 'Total Units' },
|
||||
{ key: 'highlight_top', title: 'Highlight Top', type: 'text', placeholder: 'e.g. 10, 20, 30' },
|
||||
{ key: 'highlight_color', title: 'Highlight Color', type: 'text', placeholder: 'R-G-B-A or CSS color' },
|
||||
{
|
||||
|
|
@ -224,11 +220,27 @@ const metriccampaigns = {
|
|||
showManagerTeamRank: 'yes',
|
||||
highlight_top: '',
|
||||
highlight_color: '',
|
||||
show_units_export: 'yes',
|
||||
show_normal_metrics: 'yes',
|
||||
show_combined_metrics: 'yes',
|
||||
manager_details: ['l1manager'],
|
||||
total_units_name: '',
|
||||
...item,
|
||||
};
|
||||
next.campaign_visible_roles = asArray(next.campaign_visible_roles);
|
||||
next.reporting_roles = asArray(next.reporting_roles);
|
||||
next.intervals = asArray(next.intervals);
|
||||
next.manager_details = asArray(next.manager_details);
|
||||
if (!next.manager_details.length) next.manager_details = ['l1manager'];
|
||||
next.show_units_export = next.show_units_export === 'no' ? 'no' : 'yes';
|
||||
next.show_user_attributes = ['yes', '1', 'true', true, 1].includes(next.show_user_attributes) ? 'yes' : 'no';
|
||||
next.user_attributes = asArray(next.user_attributes);
|
||||
if (next.user_attributes.length && next.show_user_attributes !== 'yes') {
|
||||
next.show_user_attributes = 'yes';
|
||||
}
|
||||
next.show_normal_metrics = next.show_normal_metrics === 'no' ? 'no' : 'yes';
|
||||
next.show_combined_metrics = next.show_combined_metrics === 'no' ? 'no' : 'yes';
|
||||
next.total_units_name = next.total_units_name || '';
|
||||
next.metrics = (Array.isArray(next.metrics) ? next.metrics : []).map(normalizeCampaignMetric);
|
||||
next.combined_metrics = parseCombinedMetrics(next.combined_metrics).map(normalizeCombined);
|
||||
next.ranking_attributes = normalizeRankingAttributes(next.ranking_attributes);
|
||||
|
|
@ -236,14 +248,6 @@ const metriccampaigns = {
|
|||
next.validity_end = toDateInput(next.validity_end);
|
||||
if (next.teams_rank) next.teams_rank = String(next.teams_rank).toLowerCase().slice(0, 2);
|
||||
if (next.sort_order === '' || next.sort_order == null) next.sort_order = 0;
|
||||
next.show_units_export = next.show_units_export || 'yes';
|
||||
next.show_user_attributes = next.show_user_attributes === 'yes' ? 'yes' : 'no';
|
||||
next.user_attributes = asArray(next.user_attributes);
|
||||
next.show_normal_metrics = next.show_normal_metrics || 'yes';
|
||||
next.show_combined_metrics = next.show_combined_metrics || 'yes';
|
||||
next.manager_details = asArray(next.manager_details);
|
||||
if (!next.manager_details.length) next.manager_details = ['l1manager'];
|
||||
next.total_units_name = next.total_units_name || '';
|
||||
return next;
|
||||
},
|
||||
beforeSave: (data) => {
|
||||
|
|
@ -283,14 +287,14 @@ const metriccampaigns = {
|
|||
campaign_visible_roles: asArray(data.campaign_visible_roles),
|
||||
reporting_roles: asArray(data.reporting_roles),
|
||||
intervals: asArray(data.intervals),
|
||||
sort_order: Number.parseInt(data.sort_order, 10) || 0,
|
||||
show_units_export: data.show_units_export || 'yes',
|
||||
manager_details: asArray(data.manager_details).length ? asArray(data.manager_details) : ['l1manager'],
|
||||
show_units_export: data.show_units_export === 'no' ? 'no' : 'yes',
|
||||
show_user_attributes: data.show_user_attributes === 'yes' ? 'yes' : 'no',
|
||||
user_attributes: asArray(data.user_attributes),
|
||||
show_normal_metrics: data.show_normal_metrics || 'yes',
|
||||
show_combined_metrics: data.show_combined_metrics || 'yes',
|
||||
manager_details: asArray(data.manager_details).length ? asArray(data.manager_details) : ['l1manager'],
|
||||
show_normal_metrics: data.show_normal_metrics === 'no' ? 'no' : 'yes',
|
||||
show_combined_metrics: data.show_combined_metrics === 'no' ? 'no' : 'yes',
|
||||
total_units_name: data.total_units_name || '',
|
||||
sort_order: Number.parseInt(data.sort_order, 10) || 0,
|
||||
validity_start: toApiDate(data.validity_start),
|
||||
validity_end: toApiDate(data.validity_end),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { signedGet, isSuccess } from '@/api/client';
|
||||
import { dropdownMap } from '@/api/dropdowns';
|
||||
import { dropdownMap, fetchUserAttributes } from '@/api/dropdowns';
|
||||
import {
|
||||
DEFAULT_INTERVAL_VALUES,
|
||||
INTERVAL_VALUES,
|
||||
|
|
@ -48,11 +48,11 @@ const metricviews = {
|
|||
sort_order: item.sort_order ?? item.widget_sort_order ?? 0,
|
||||
})),
|
||||
formFields: async () => {
|
||||
const [metrics, roles, contactRes, userAttrRes] = await Promise.all([
|
||||
const [metrics, roles, contactRes, userAttrItems] = await Promise.all([
|
||||
metricOptions(),
|
||||
dropdownMap('ums', 'roles', (item) => item.name),
|
||||
signedGet('contact', 'contactattribute/get_all'),
|
||||
signedGet('ums', 'userattribute/get_all'),
|
||||
fetchUserAttributes(),
|
||||
]);
|
||||
const contactAttributes = {};
|
||||
const contactItems = isSuccess(contactRes) ? contactRes.data?.items || [] : [];
|
||||
|
|
@ -60,7 +60,6 @@ const metricviews = {
|
|||
if (attr?.code) contactAttributes[attr.code] = attr.attribute || attr.code;
|
||||
});
|
||||
const userAttributes = {};
|
||||
const userAttrItems = isSuccess(userAttrRes) ? userAttrRes.data?.items || [] : [];
|
||||
userAttrItems.forEach((attr) => {
|
||||
if (!attr?.code || attr.status === 'disabled') return;
|
||||
userAttributes[attr.code] = attr.attribute || attr.code;
|
||||
|
|
@ -189,10 +188,8 @@ const metricviews = {
|
|||
types: VIEW_COMBINED_TYPES,
|
||||
},
|
||||
{ key: 'report_roles', title: 'Data Report from Roles', type: 'multiselect', required: true, values: roles },
|
||||
{ key: 'team_leaderboard_widget_roles', title: 'Team Leaderboard Widget Roles', type: 'multiselect', values: roles },
|
||||
{ key: 'status', title: 'Status', type: 'select', required: true, values: { active: 'Active', disabled: 'Disabled' } },
|
||||
{ key: 'show_positions', title: 'Show Positions', type: 'select', required: true, values: { yes: 'Yes', no: 'No' } },
|
||||
{ key: 'stateheadwise_leaderboard', title: 'Stateheadwise Leaderboard', type: 'select', required: true, values: { yes: 'Yes', no: 'No' }, default: 'no' },
|
||||
{
|
||||
key: 'show_positions_type',
|
||||
title: 'Show Positions Type',
|
||||
|
|
@ -201,6 +198,12 @@ const metricviews = {
|
|||
values: { all: 'All', loggedin: 'Logged In' },
|
||||
},
|
||||
{ key: 'contact_attributes', title: 'Include Doctor Attributes', type: 'multiselect', values: contactAttributes },
|
||||
{
|
||||
key: 'team_leaderboard_widget_roles',
|
||||
title: 'Team Leaderboard Widget Roles',
|
||||
type: 'multiselect',
|
||||
values: roles,
|
||||
},
|
||||
{ key: 'show_user_attributes', title: 'Show User Attributes', type: 'yesno', required: true, default: 'no' },
|
||||
{
|
||||
key: 'user_attributes',
|
||||
|
|
@ -208,7 +211,13 @@ const metricviews = {
|
|||
type: 'multiselect',
|
||||
values: userAttributes,
|
||||
default: [],
|
||||
showIf: { show_user_attributes: 'yes' },
|
||||
},
|
||||
{
|
||||
key: 'stateheadwise_leaderboard',
|
||||
title: 'Stateheadwise Leaderboard',
|
||||
type: 'yesno',
|
||||
required: true,
|
||||
default: 'no',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
|
@ -220,16 +229,16 @@ const metricviews = {
|
|||
show_positions: 'yes',
|
||||
show_positions_type: 'all',
|
||||
show_user_attributes: 'no',
|
||||
stateheadwise_leaderboard: 'no',
|
||||
user_attributes: [],
|
||||
stateheadwise_leaderboard: 'no',
|
||||
list_chart_type: 'progressbar',
|
||||
value_views: ['value'],
|
||||
default_tab: '',
|
||||
team_leaderboard_widget_roles: [],
|
||||
...item,
|
||||
};
|
||||
next.designations = asArray(next.designations);
|
||||
next.report_roles = asArray(next.report_roles);
|
||||
next.team_leaderboard_widget_roles = asArray(next.team_leaderboard_widget_roles);
|
||||
next.graphical_views = asArray(next.graphical_views);
|
||||
next.details_views = asArray(next.details_views);
|
||||
next.trend_views = asArray(next.trend_views);
|
||||
|
|
@ -244,9 +253,15 @@ const metricviews = {
|
|||
next.default_tab = next.default_tab || '';
|
||||
next.intervals = asArray(next.intervals);
|
||||
next.contact_attributes = asArray(next.contact_attributes);
|
||||
next.show_user_attributes = next.show_user_attributes === 'yes' ? 'yes' : 'no';
|
||||
next.stateheadwise_leaderboard = next.stateheadwise_leaderboard === 'yes' ? 'yes' : 'no';
|
||||
next.team_leaderboard_widget_roles = asArray(
|
||||
next.team_leaderboard_widget_roles || next.leaderboard_widget_roles || next.widget_roles,
|
||||
);
|
||||
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.stateheadwise_leaderboard = next.stateheadwise_leaderboard === 'yes' ? 'yes' : 'no';
|
||||
next.combined_metrics = parseCombinedMetrics(next.combined_metrics);
|
||||
next.validity_start = toDateInput(next.validity_start);
|
||||
next.validity_end = toDateInput(next.validity_end);
|
||||
|
|
@ -304,7 +319,6 @@ const metricviews = {
|
|||
metrics,
|
||||
designations: asArray(data.designations),
|
||||
report_roles: asArray(data.report_roles),
|
||||
team_leaderboard_widget_roles: asArray(data.team_leaderboard_widget_roles),
|
||||
graphical_views: asArray(data.graphical_views),
|
||||
details_views: asArray(data.details_views),
|
||||
trend_views: asArray(data.trend_views),
|
||||
|
|
@ -315,9 +329,10 @@ const metricviews = {
|
|||
default_tab: data.default_tab || '',
|
||||
intervals: asArray(data.intervals),
|
||||
contact_attributes: asArray(data.contact_attributes),
|
||||
team_leaderboard_widget_roles: asArray(data.team_leaderboard_widget_roles),
|
||||
show_user_attributes: data.show_user_attributes === 'yes' ? 'yes' : 'no',
|
||||
stateheadwise_leaderboard: data.stateheadwise_leaderboard === 'yes' ? 'yes' : 'no',
|
||||
user_attributes: asArray(data.user_attributes),
|
||||
stateheadwise_leaderboard: data.stateheadwise_leaderboard === 'yes' ? 'yes' : 'no',
|
||||
combined_metrics: stringifyCombinedMetrics(
|
||||
(data.combined_metrics || [])
|
||||
.filter((row) => row.type)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { signedGet, isSuccess } from '@/api/client';
|
||||
import { dropdownMap } from '@/api/dropdowns';
|
||||
import { dropdownMap, fetchUserAttributes } from '@/api/dropdowns';
|
||||
import { metricOptions, toDateInput } from '@/helpers/metric';
|
||||
|
||||
const STATUS = { active: 'Active', disabled: 'Disabled' };
|
||||
|
|
@ -159,7 +159,7 @@ const milestones = {
|
|||
{ key: 'status', title: 'Status', type: 'select' },
|
||||
],
|
||||
formFields: async () => {
|
||||
const [metrics, roles, badges, categories, images, userAttrRes] = await Promise.all([
|
||||
const [metrics, roles, badges, categories, images, userAttrItems] = await Promise.all([
|
||||
metricOptions(),
|
||||
dropdownMap('ums', 'roles', (item) => item.name),
|
||||
getAllItems('recognition', 'badge_list/get_all').then(titleMap),
|
||||
|
|
@ -171,10 +171,9 @@ const milestones = {
|
|||
});
|
||||
return values;
|
||||
}),
|
||||
signedGet('ums', 'userattribute/get_all'),
|
||||
fetchUserAttributes(),
|
||||
]);
|
||||
const userAttributes = {};
|
||||
const userAttrItems = isSuccess(userAttrRes) ? userAttrRes.data?.items || [] : [];
|
||||
userAttrItems.forEach((attr) => {
|
||||
if (!attr?.code || attr.status === 'disabled') return;
|
||||
userAttributes[attr.code] = attr.attribute || attr.code;
|
||||
|
|
@ -208,8 +207,8 @@ const milestones = {
|
|||
required: true,
|
||||
values: { all: 'All', loggedin: 'Logged In' },
|
||||
},
|
||||
{ key: 'show_units_export', title: 'Show Units Export', type: 'select', required: true, values: YES_NO, default: 'yes' },
|
||||
{ key: 'show_user_attributes', title: 'Show User Attributes', type: 'select', required: true, values: YES_NO, default: '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',
|
||||
|
|
@ -218,10 +217,10 @@ const milestones = {
|
|||
default: [],
|
||||
showIf: { show_user_attributes: 'yes' },
|
||||
},
|
||||
{ key: 'show_normal_metrics', title: 'Show Normal Metrics', type: 'select', required: true, values: YES_NO, default: 'yes' },
|
||||
{ key: 'show_combined_metrics', title: 'Show Combined Metrics', type: 'select', required: true, values: YES_NO, default: 'yes' },
|
||||
{ key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS, default: ['l1manager'] },
|
||||
{ key: 'total_units_name', title: 'Total Units Name', type: 'text', default: '' },
|
||||
{ key: 'show_normal_metrics', title: 'Show Normal Metrics', type: 'select', values: YES_NO },
|
||||
{ key: 'show_combined_metrics', title: 'Show Combined Metrics', type: 'select', values: YES_NO },
|
||||
{ key: 'manager_details', title: 'Manager Details', type: 'multiselect', values: MANAGER_LEVELS },
|
||||
{ key: 'total_units_name', title: 'Total Units Name', type: 'text', placeholder: 'Total Units' },
|
||||
{ key: 'send_report', title: 'Send Report', type: 'select', required: true, values: YES_NO },
|
||||
{
|
||||
key: 'performance_period',
|
||||
|
|
@ -303,6 +302,18 @@ const milestones = {
|
|||
values: YES_NO,
|
||||
showIf: { send_report: 'yes' },
|
||||
},
|
||||
{
|
||||
key: 'subject',
|
||||
title: 'Report Subject',
|
||||
type: 'text',
|
||||
showIf: { send_report: 'yes' },
|
||||
},
|
||||
{
|
||||
key: 'body',
|
||||
title: 'Report Body',
|
||||
type: 'textarea',
|
||||
showIf: { send_report: 'yes' },
|
||||
},
|
||||
{
|
||||
key: 'metrics',
|
||||
title: 'Metric Mapping',
|
||||
|
|
@ -353,12 +364,17 @@ const milestones = {
|
|||
next.send_pending_users = fromBool(reportConfig.send_pending_users ?? false);
|
||||
next.show_target_average = fromBool(reportConfig.show_target_average ?? false);
|
||||
next.show_overall_average = fromBool(reportConfig.show_overall_average ?? false);
|
||||
next.subject = reportConfig.subject || '';
|
||||
next.body = reportConfig.body || '';
|
||||
next.send_report = fromBool(next.send_report ?? false);
|
||||
next.show_units_export = next.show_units_export || 'yes';
|
||||
next.show_user_attributes = next.show_user_attributes === 'yes' ? 'yes' : 'no';
|
||||
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);
|
||||
next.show_normal_metrics = next.show_normal_metrics || 'yes';
|
||||
next.show_combined_metrics = next.show_combined_metrics || 'yes';
|
||||
if (next.user_attributes.length && next.show_user_attributes !== 'yes') {
|
||||
next.show_user_attributes = 'yes';
|
||||
}
|
||||
next.show_normal_metrics = next.show_normal_metrics === 'no' ? 'no' : 'yes';
|
||||
next.show_combined_metrics = next.show_combined_metrics === 'no' ? 'no' : 'yes';
|
||||
next.manager_details = toStringList(next.manager_details);
|
||||
if (!next.manager_details.length) next.manager_details = ['l1manager'];
|
||||
next.total_units_name = next.total_units_name || '';
|
||||
|
|
@ -375,14 +391,6 @@ const milestones = {
|
|||
payload.milestones = mapMilestonesForSave(payload.milestones);
|
||||
|
||||
const daysOrDates = payload.days_or_dates || 'days';
|
||||
payload.show_units_export = payload.show_units_export || '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 || 'yes';
|
||||
payload.show_combined_metrics = payload.show_combined_metrics || 'yes';
|
||||
payload.manager_details = toStringList(payload.manager_details);
|
||||
if (!payload.manager_details.length) payload.manager_details = ['l1manager'];
|
||||
payload.total_units_name = payload.total_units_name || '';
|
||||
payload.send_report = toBool(payload.send_report ?? 'no');
|
||||
payload.report_config = {
|
||||
days_or_dates: daysOrDates,
|
||||
|
|
@ -397,7 +405,18 @@ const milestones = {
|
|||
send_pending_users: toBool(payload.send_pending_users ?? 'no'),
|
||||
show_target_average: toBool(payload.show_target_average ?? 'no'),
|
||||
show_overall_average: toBool(payload.show_overall_average ?? 'no'),
|
||||
subject: (payload.subject || '').trim(),
|
||||
body: (payload.body || '').trim(),
|
||||
};
|
||||
payload.show_units_export = payload.show_units_export === 'no' ? 'no' : 'yes';
|
||||
payload.show_user_attributes = payload.show_user_attributes === 'yes' ? 'yes' : 'no';
|
||||
payload.user_attributes = toStringList(payload.user_attributes);
|
||||
payload.show_normal_metrics = payload.show_normal_metrics === 'no' ? 'no' : 'yes';
|
||||
payload.show_combined_metrics = payload.show_combined_metrics === 'no' ? 'no' : 'yes';
|
||||
payload.manager_details = toStringList(payload.manager_details).length
|
||||
? toStringList(payload.manager_details)
|
||||
: ['l1manager'];
|
||||
payload.total_units_name = payload.total_units_name || '';
|
||||
|
||||
delete payload.interval_start_date;
|
||||
delete payload.interval_end_date;
|
||||
|
|
@ -413,6 +432,8 @@ const milestones = {
|
|||
delete payload.send_pending_users;
|
||||
delete payload.show_target_average;
|
||||
delete payload.show_overall_average;
|
||||
delete payload.subject;
|
||||
delete payload.body;
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const rcpacompetitors = {
|
|||
{ title: 'Back to Products', to: '/rcpaproducts' },
|
||||
],
|
||||
formFields: () => [
|
||||
{ key: 'product_id', title: 'Product', type: 'hidden' },
|
||||
{ key: 'company', title: 'Company', type: 'text', required: true },
|
||||
{ key: 'brand', title: 'Brand', type: 'text', required: true },
|
||||
{ key: 'title', title: 'Product Name', type: 'text', required: true },
|
||||
|
|
@ -31,12 +32,22 @@ const rcpacompetitors = {
|
|||
{ key: 'nrv_value', title: 'NRV Value', type: 'number', required: true },
|
||||
{ key: 'sort_order', title: 'Sort Order', type: 'number', required: true },
|
||||
],
|
||||
beforeForm: (item, _mode, ctx) => ({
|
||||
...item,
|
||||
product_id: item.product_id || ctx?.searchParams?.get('product_id') || '',
|
||||
}),
|
||||
beforeForm: (item, _mode, ctx) => {
|
||||
const fromUrl = ctx?.searchParams?.get('product_id') || '';
|
||||
const stored = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('rcpa_product_id') || '' : '';
|
||||
const product_id = item.product_id || fromUrl || stored;
|
||||
if (product_id && typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.setItem('rcpa_product_id', product_id);
|
||||
}
|
||||
return { ...item, product_id };
|
||||
},
|
||||
beforeSave: (data) => {
|
||||
const payload = { ...data };
|
||||
const stored = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem('rcpa_product_id') || '' : '';
|
||||
const product_id = data.product_id || stored;
|
||||
if (product_id && typeof sessionStorage !== 'undefined') {
|
||||
sessionStorage.setItem('rcpa_product_id', product_id);
|
||||
}
|
||||
const payload = { ...data, product_id };
|
||||
if (!payload.product_id) delete payload.product_id;
|
||||
return payload;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,7 +21,15 @@ const rcpaforms = {
|
|||
baseUrl: '/rcpaform',
|
||||
listPath: '/rcpaforms',
|
||||
listFields: [
|
||||
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
|
||||
{
|
||||
key: 'actions',
|
||||
title: 'Actions',
|
||||
buttons: [
|
||||
'edit',
|
||||
'delete',
|
||||
{ name: 'questions', to: (row) => `/formquestions?id=${row.id}`, title: 'Questions' },
|
||||
],
|
||||
},
|
||||
{ key: 'title', title: 'Title', type: 'text' },
|
||||
{ key: 'workflow', title: 'Workflow', type: 'text' },
|
||||
{ key: 'status', title: 'Status', type: 'select' },
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ function flattenReminders(rows) {
|
|||
checksubmission_days: item.checksubmission_days || '',
|
||||
message: item.message || '',
|
||||
plan_date_field: item.plan_date_field || '',
|
||||
notification_type: ['normal', 'overlay_custom'].includes(item.notification_type)
|
||||
? item.notification_type
|
||||
: 'normal',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -108,6 +111,7 @@ const reminders = {
|
|||
checksubmission_days: row.checksubmission === 'yes' ? row.checksubmission_days || '' : '',
|
||||
message: row.message || '',
|
||||
plan_date_field: row.plan_date_field || '',
|
||||
notification_type: row.notification_type === 'overlay_custom' ? 'overlay_custom' : 'normal',
|
||||
}));
|
||||
return payload;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import { dropdownMap } from '@/api/dropdowns';
|
||||
|
||||
const OPTION_TYPES = ['select', 'multiselect', 'checkbox', 'radio'];
|
||||
|
||||
const userattributes = {
|
||||
service: 'ums',
|
||||
module: 'userattribute',
|
||||
|
|
@ -40,7 +42,7 @@ const userattributes = {
|
|||
{ key: 'roles', title: 'Roles', type: 'multiselect', values: roles },
|
||||
{ key: 'sort_order', title: 'Sort Order', type: 'number', required: true, default: 0 },
|
||||
{ key: 'status', title: 'Status', type: 'select', required: true, values: { active: 'Active', disabled: 'Disabled' } },
|
||||
{ key: 'options', title: 'Options', type: 'mapping', fullwidth: true },
|
||||
{ key: 'options', title: 'Options', type: 'mapping', fullwidth: true, showIf: { type: OPTION_TYPES } },
|
||||
];
|
||||
},
|
||||
beforeForm: (item) => ({
|
||||
|
|
@ -52,11 +54,10 @@ const userattributes = {
|
|||
sort_order: item.sort_order ?? 0,
|
||||
}),
|
||||
beforeSave: (data) => {
|
||||
const optionTypes = ['select', 'multiselect', 'checkbox', 'radio'];
|
||||
return {
|
||||
...data,
|
||||
roles: Array.isArray(data.roles) ? data.roles : [],
|
||||
options: optionTypes.includes(data.type) ? data.options || [] : [],
|
||||
options: OPTION_TYPES.includes(data.type) ? data.options || [] : [],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,105 @@ import { signedGet, isSuccess } from '@/api/client';
|
|||
import { dropdownMap } from '@/api/dropdowns';
|
||||
import { formatRoleNames } from '@/helpers/roles';
|
||||
|
||||
const EXPORT_COLUMNS = [
|
||||
{ key: 'id', title: 'Id' },
|
||||
{ key: 'employee_code', title: 'Employee Code' },
|
||||
{ key: 'firstname', title: 'Firstname' },
|
||||
{ key: 'lastname', title: 'Lastname' },
|
||||
{ key: 'email', title: 'Email' },
|
||||
{ key: 'logged_in', title: 'Is Logged In' },
|
||||
{ key: 'roles', title: 'Roles' },
|
||||
{ key: 'phone', title: 'Phone' },
|
||||
{ key: 'designation', title: 'Designation' },
|
||||
{ key: 'state', title: 'State' },
|
||||
{ key: 'city', title: 'City' },
|
||||
{ key: 'hierarchy', title: 'Hierarchy' },
|
||||
{ key: 'status', title: 'Status' },
|
||||
{ key: 'createdAt', title: 'Created At' },
|
||||
{ key: 'updatedAt', title: 'Updated At' },
|
||||
];
|
||||
|
||||
const EXPORT_SKIP = new Set(['avatar', 'channels', 'domain', 'partition', 'user_id']);
|
||||
const EXPORT_FIXED_KEYS = new Set([
|
||||
...EXPORT_COLUMNS.map((column) => column.key),
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]);
|
||||
|
||||
function formatExportHeading(key) {
|
||||
return String(key || '')
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.trim()
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function extraExportKeys(items) {
|
||||
const keys = [];
|
||||
const seen = new Set();
|
||||
(items || []).forEach((item) => {
|
||||
if (!item || typeof item !== 'object') return;
|
||||
Object.keys(item).forEach((key) => {
|
||||
if (EXPORT_SKIP.has(key) || EXPORT_FIXED_KEYS.has(key) || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
keys.push(key);
|
||||
});
|
||||
});
|
||||
return keys;
|
||||
}
|
||||
|
||||
function formatHierarchy(value) {
|
||||
if (value == null || value === '') return '-';
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value
|
||||
.map((part) => {
|
||||
if (part == null || part === '') return '';
|
||||
if (typeof part === 'object') return part.name || part.code || part.id || '';
|
||||
return String(part);
|
||||
})
|
||||
.filter(Boolean);
|
||||
return parts.join(' > ') || '-';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
if (value.name && value.code) return `${value.name} (${value.code})`;
|
||||
return value.name || value.code || value.id || JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function exportCell(value, formatDate, key) {
|
||||
if (value == null || value === '') return '-';
|
||||
if (Array.isArray(value)) {
|
||||
if (!value.length) return '-';
|
||||
if (value.some((part) => part && typeof part === 'object' && (part.name || part.id))) {
|
||||
return formatRoleNames(value) || '-';
|
||||
}
|
||||
return value
|
||||
.map((part) => (part && typeof part === 'object' ? JSON.stringify(part) : part))
|
||||
.filter((part) => part !== null && part !== undefined && part !== '')
|
||||
.join(', ') || '-';
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
if (value.name) return value.name;
|
||||
if (value.id) return value.id;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (formatDate && /(?:At|_at|Date|_date)$/.test(String(key || ''))) {
|
||||
return formatDate(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function exportColumnValue(user, key, formatDate) {
|
||||
if (key === 'roles') return user.roles ? formatRoleNames(user.roles) || '-' : '-';
|
||||
if (key === 'hierarchy') return formatHierarchy(user.hierarchy);
|
||||
if (key === 'createdAt') return user.createdAt || user.created_at ? formatDate(user.createdAt || user.created_at) : '-';
|
||||
if (key === 'updatedAt') return user.updatedAt || user.updated_at ? formatDate(user.updatedAt || user.updated_at) : '-';
|
||||
const value = user?.[key];
|
||||
if (value == null || value === '') return '-';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
const users = {
|
||||
service: 'ums',
|
||||
module: 'users',
|
||||
|
|
@ -33,36 +132,15 @@ const users = {
|
|||
exportCsv: {
|
||||
params: { start: 0, length: 9999, search: JSON.stringify({}), keyword: '', export: 1 },
|
||||
filename: 'users.csv',
|
||||
headers: [
|
||||
'id',
|
||||
'Employee Code',
|
||||
'Firstname',
|
||||
'Lastname',
|
||||
'Email',
|
||||
'Is Logged In',
|
||||
'Roles',
|
||||
'Phone',
|
||||
'Designation',
|
||||
'State',
|
||||
'City',
|
||||
'Status',
|
||||
'Created At',
|
||||
],
|
||||
row: (user, formatDate) => [
|
||||
user.id ?? '-',
|
||||
user.employee_code ?? '-',
|
||||
user.firstname ?? '-',
|
||||
user.lastname ?? '-',
|
||||
user.email ?? '-',
|
||||
user.logged_in ?? '-',
|
||||
user.roles ? formatRoleNames(user.roles) || '-' : '-',
|
||||
user.phone ?? '-',
|
||||
user.designation ?? '-',
|
||||
user.state ?? '-',
|
||||
user.city ?? '-',
|
||||
user.status ?? '-',
|
||||
user.createdAt ? formatDate(user.createdAt) : '-',
|
||||
],
|
||||
build: (items, formatDate) => {
|
||||
const extras = extraExportKeys(items);
|
||||
const headers = [...EXPORT_COLUMNS.map((column) => column.title), ...extras.map(formatExportHeading)];
|
||||
const rows = (items || []).map((user) => [
|
||||
...EXPORT_COLUMNS.map((column) => exportColumnValue(user, column.key, formatDate)),
|
||||
...extras.map((key) => exportCell(user?.[key], formatDate, key)),
|
||||
]);
|
||||
return [headers, ...rows];
|
||||
},
|
||||
},
|
||||
formFields: async () => {
|
||||
const domainRes = await signedGet('ums', 'domain/get_one');
|
||||
|
|
|
|||
|
|
@ -85,13 +85,23 @@ const MagMediaViewers = lazy(() => import('@/views/content/MagMediaViewers'));
|
|||
const WorkflowEditor = lazy(() => import('@/views/workflow/Editor'));
|
||||
const WorkflowMonitor = lazy(() => import('@/views/workflow/Monitor'));
|
||||
const LeaderboardDashboard = lazy(() => import('@/views/leaderboard/Dashboard'));
|
||||
const NebReportList = lazy(() => import('@/views/neblogistic/ReportList'));
|
||||
const NebReportDetail = lazy(() => import('@/views/neblogistic/ReportDetail'));
|
||||
const NebDepotPage = lazy(() => import('@/views/neblogistic/DepotPage'));
|
||||
const NebMappingPage = lazy(() => import('@/views/neblogistic/MappingPage'));
|
||||
const NebImportPage = lazy(() => import('@/views/neblogistic/ImportPage'));
|
||||
const RcpaTmWise = lazy(() => import('@/views/rcpareport/TmWise'));
|
||||
const RcpaMonthWise = lazy(() => import('@/views/rcpareport/MonthWise'));
|
||||
const RcpaFormWise = lazy(() => import('@/views/rcpareport/FormWise'));
|
||||
const GennovadcDashboard = lazy(() => import('@/views/gennovadcreport/Dashboard'));
|
||||
|
||||
function moduleRoutes(config) {
|
||||
const routes = [
|
||||
{ path: config.listPath.replace(/^\//, ''), element: <EntityListPage module={config} /> },
|
||||
];
|
||||
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) {
|
||||
routes.push({ path: `${config.baseUrl.replace(/^\//, '')}/edit`, element: <EntityFormPage module={config} /> });
|
||||
|
|
@ -132,6 +142,8 @@ export const routes = [
|
|||
{ path: '/team/players', element: <TeamPlayers /> },
|
||||
...moduleRoutes(contacts),
|
||||
...moduleRoutes(contactexports),
|
||||
{ path: 'contactexports', element: <Navigate to="/contact/export" replace /> },
|
||||
{ path: 'contactexport/addnew', element: <Navigate to="/contact/exportcreate" replace /> },
|
||||
...moduleRoutes(contactattributes),
|
||||
{
|
||||
path: '/contact/import',
|
||||
|
|
@ -204,6 +216,15 @@ export const routes = [
|
|||
...moduleRoutes(tracks),
|
||||
{ path: '/metricreports', element: <MetricReportList /> },
|
||||
{ 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: '*', 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: '',
|
||||
};
|
||||
|
||||
const CHILDREN_PARAMS = { children: 1, source: 'elastic' };
|
||||
function hierarchyItems(data) {
|
||||
if (Array.isArray(data?.items)) return data.items;
|
||||
if (Array.isArray(data?.hierarchys)) return data.hierarchys;
|
||||
if (Array.isArray(data)) return data;
|
||||
return [];
|
||||
}
|
||||
|
||||
const CHILDREN_PARAMS = { children: 1 };
|
||||
|
||||
function createExpandStore() {
|
||||
let state = {};
|
||||
|
|
@ -249,10 +256,15 @@ export default function HierarchyTree() {
|
|||
if (!parentId || isCrestroad()) return [];
|
||||
setLoadingIds((prev) => ({ ...prev, [parentId]: true }));
|
||||
try {
|
||||
const response = await signedGet('ums', 'hierarchy/get_all', {
|
||||
...CHILDREN_PARAMS,
|
||||
parent_id: parentId,
|
||||
});
|
||||
const response = await signedGet(
|
||||
'ums',
|
||||
'hierarchy/get_all',
|
||||
{
|
||||
...CHILDREN_PARAMS,
|
||||
parent_id: parentId,
|
||||
},
|
||||
{ timeout: 120000 },
|
||||
);
|
||||
if (response?.statusCode === 401 || response?.statusCode === 403) {
|
||||
if (!silent) setError('Unauthorised');
|
||||
return [];
|
||||
|
|
@ -261,7 +273,7 @@ export default function HierarchyTree() {
|
|||
if (!silent) setError(response?.message || 'Unable to load hierarchy');
|
||||
return [];
|
||||
}
|
||||
const items = response.data?.items || [];
|
||||
const items = hierarchyItems(response.data);
|
||||
mergeChildren(parentId, items);
|
||||
return items;
|
||||
} catch (err) {
|
||||
|
|
@ -285,7 +297,7 @@ export default function HierarchyTree() {
|
|||
const crestroad = isCrestroad();
|
||||
const listRes = crestroad
|
||||
? await signedGet('catalog', 'activity/hierarchy_list')
|
||||
: await signedGet('ums', 'hierarchy/get_all', CHILDREN_PARAMS);
|
||||
: await signedGet('ums', 'hierarchy/get_all', CHILDREN_PARAMS, { timeout: 120000 });
|
||||
if (listRes?.statusCode === 401 || listRes?.statusCode === 403) {
|
||||
setError('Unauthorised');
|
||||
return;
|
||||
|
|
@ -295,7 +307,7 @@ export default function HierarchyTree() {
|
|||
return;
|
||||
}
|
||||
const me = listRes.data?.me;
|
||||
const items = listRes.data?.items || [];
|
||||
const items = hierarchyItems(listRes.data);
|
||||
if (crestroad) {
|
||||
const ingested = ingestFullTree(items, me);
|
||||
setNodesById(ingested.nodes);
|
||||
|
|
@ -371,10 +383,10 @@ export default function HierarchyTree() {
|
|||
expandStore.collapse(id);
|
||||
return;
|
||||
}
|
||||
expandStore.expand(id);
|
||||
if (!isCrestroad() && childrenByIdRef.current[id] === undefined) {
|
||||
await fetchChildren(id);
|
||||
}
|
||||
expandStore.expand(id);
|
||||
},
|
||||
[expandStore, fetchChildren],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import { defineConfig , loadEnv } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ const localServices = {
|
|||
vgreet: 4003,
|
||||
};
|
||||
|
||||
const proxy = Object.fromEntries(
|
||||
const localProxy = Object.fromEntries(
|
||||
Object.entries(localServices).map(([service, port]) => [
|
||||
`/api/${service}`,
|
||||
{
|
||||
|
|
@ -34,15 +34,43 @@ const proxy = Object.fromEntries(
|
|||
]),
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
const productionProxy = {
|
||||
'/api/ums/hierarchy/get_all': {
|
||||
target: 'https://ljtw6cnu6kzxm3s76upgsvmz5u0ufrep.lambda-url.ap-south-1.on.aws',
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
rewrite: () => '/',
|
||||
},
|
||||
'/api': {
|
||||
target: 'https://rapportapi.click',
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||
},
|
||||
};
|
||||
|
||||
const laravelProxy = {
|
||||
'/laravel-api': {
|
||||
target: process.env.VITE_LARAVEL_ORIGIN || 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/laravel-api/, '/api'),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '');
|
||||
return {
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: { '@': resolve(__dirname, 'src') },
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
...laravelProxy,
|
||||
...(env.VITE_API_MODE === 'local' ? localProxy : productionProxy),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue