Merge remote-tracking branch 'origin/main'

This commit is contained in:
Ravi Gulhane 2026-10-22 13:06:11 +05:30
commit 22c4391380
51 changed files with 3632 additions and 236 deletions

2
.env.localapi Normal file
View File

@ -0,0 +1,2 @@
VITE_API_MODE=local
VITE_LARAVEL_ORIGIN=http://127.0.0.1:8000

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
*.cursor/

View File

@ -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",

View File

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

View File

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

View File

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

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

@ -0,0 +1,59 @@
import axios from 'axios';
import { getAccessToken, getSession } from '@/auth/storage';
export function laravelUrl(path) {
const clean = String(path || '').replace(/^\//, '');
if (import.meta.env.DEV) return `/laravel-api/${clean}`;
const base = (import.meta.env.VITE_LARAVEL_URL || 'https://web.buildrapport.co').replace(/\/$/, '');
return `${base}/api/${clean}`;
}
export async function laravelRequest(method, path, data = {}, { timeout = 60000, responseType } = {}) {
const token = getAccessToken();
const session = getSession();
const headers = {};
if (token) headers.Authorization = token;
if (session?.user?.id) headers['X-Rapport-User-Id'] = session.user.id;
if (session?.domain || session?.user?.domain) headers['X-Rapport-Domain'] = session.domain || session.user.domain;
const config = {
method,
url: laravelUrl(path),
headers,
timeout,
withCredentials: false,
};
if (responseType) config.responseType = responseType;
if (method === 'get') config.params = data;
else if (data instanceof FormData) {
config.data = data;
config.headers['Content-Type'] = 'multipart/form-data';
} else config.data = data;
try {
const response = await axios(config);
return responseType ? response : response.data;
} catch (error) {
const payload = error.response?.data;
if (payload && typeof payload === 'object') return payload;
return {
statusCode: error.response?.status || 0,
status: 'error',
message:
error.message === 'Network Error'
? 'Network Error: Laravel API was blocked or unreachable. Start Rapport-Laravel and retry.'
: error.message || 'Network Error',
};
}
}
export function laravelGet(path, data = {}, options = {}) {
return laravelRequest('get', path, data, options);
}
export function laravelPost(path, data = {}, options = {}) {
return laravelRequest('post', path, data, options);
}
export function isLaravelSuccess(payload) {
return payload && (payload.statusCode === 200 || payload.status === 'success');
}

View File

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

View File

@ -0,0 +1,67 @@
.gennova-report-scroll {
overflow-x: auto;
width: 100%;
}
.gennova-report-loading {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--#{$prefix}secondary-bg) 70%, transparent);
}
.gennova-report-table {
border-collapse: separate;
border-spacing: 0;
th.sticky-first-col,
td.sticky-first-col {
position: sticky;
left: 0;
z-index: 2;
min-width: 120px;
background-color: var(--#{$prefix}secondary-bg);
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
}
thead th.sticky-first-col {
z-index: 4;
}
thead th.sticky-first-col,
tr.table-secondary td.sticky-first-col {
background-color: var(--#{$prefix}table-bg);
}
}
.transplant-summary-table {
th,
td {
border: 1px solid var(--#{$prefix}border-color);
vertical-align: middle;
}
.ts-section-end {
border-right: 2px solid var(--#{$prefix}secondary);
}
.ts-group-start {
border-left: 2px solid var(--#{$prefix}secondary);
}
}
.gennova-hier-toggle {
width: 220px;
}
.gennova-hier-menu {
min-width: 280px;
}
.gennova-hier-options {
max-height: 240px;
overflow-y: auto;
}

View File

@ -0,0 +1,29 @@
.rcpa-report-grid {
height: calc(100vh - 240px);
width: 100%;
&.ag-theme-alpine {
--ag-header-column-separator-display: none;
}
}
.rcpa-report-loading {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--bs-body-bg) 70%, transparent);
}
.header-cell {
background-color: #a6e1ec;
}
.rcpa-report-filters {
.form-select,
.form-control {
max-width: 280px;
}
}

View File

@ -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 (
<Controller
name={field.key}
control={control}
rules={{ required: field.required }}
render={({ field: rhf }) => (
<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 })} />
<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>
<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} type="checkbox" label={label} value={value} {...register(field.key)} />
<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>
);
})}

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

@ -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)),

View File

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

View File

@ -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([

View File

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

View File

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

View File

@ -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) => ({
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;

View File

@ -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 : [],
}),
};

View File

@ -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) => ({
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;
},
};

View File

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

View File

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

View File

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

View File

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

View File

@ -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' },

View File

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

View File

@ -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 || [] : [],
};
},
};

View File

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

View File

@ -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 /> },
],

View File

@ -0,0 +1,188 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Container, Form } from 'react-bootstrap';
import { isSuccess, signedGet } from '@/api/client';
import { useAuth } from '@/auth/AuthProvider';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import HierarchyFilter from './HierarchyFilter';
import ReportSection from './ReportSection';
import {
buildActivitySummary,
buildCenterSummary,
buildMonthlySummary,
buildTransplantPenetration,
buildTransplantSummary,
buildTrendSummary,
buildWeeklySummary,
currentMonth,
HIERARCHY_PARTITION,
REPORT_TIMEOUT,
reportData,
reportMonths,
} from './helpers';
const SECTIONS = [
{
id: 'monthly',
title: (month) => `Monthly Summary (${month})`,
path: 'gennovadc_reports/monthly_summary',
build: buildMonthlySummary,
filename: 'monthsummary-report.xlsx',
},
{
id: 'weekly',
title: (month) => `Weekly Summary (${month})`,
path: 'gennovadc_reports/weekly_summary',
build: buildWeeklySummary,
filename: 'weekly-summary.xlsx',
},
{
id: 'center',
title: () => 'Center Summary',
path: 'gennovadc_reports/center_summary',
build: buildCenterSummary,
filename: 'center-summary.xlsx',
},
{
id: 'centerTrend',
title: () => 'Center Trend Summary',
path: 'gennovadc_reports/center_trend_summary',
build: buildTrendSummary,
filename: 'center-trend-summary.xlsx',
monthless: true,
},
{
id: 'unitTrend',
title: () => 'Unit Trend Summary',
path: 'gennovadc_reports/unit_trend_summary',
build: buildTrendSummary,
filename: 'unit-trend-summary.xlsx',
monthless: true,
},
{
id: 'transplantPenetration',
title: () => 'Transplant Penetration',
path: 'gennovadc_reports/transplant_penetration',
build: buildTransplantPenetration,
filename: 'transplant-penetration.xlsx',
sticky: true,
},
{
id: 'transplantSummary',
title: () => 'Transplant Summary',
path: 'gennovadc_reports/transplant_summary',
build: buildTransplantSummary,
filename: 'transplant-summary.xlsx',
sticky: true,
},
{
id: 'activity',
title: () => 'Activity Summary',
path: 'gennovadc_reports/activity_summary',
build: buildActivitySummary,
filename: 'activity-summary.xlsx',
sticky: true,
},
];
const MONTH_SECTIONS = SECTIONS.filter((section) => !section.monthless);
const TREND_SECTIONS = SECTIONS.filter((section) => section.monthless);
const EMPTY_TABLE = { columns: [], rows: [], total: null };
export default function GennovadcDashboard() {
const { hasPermission } = useAuth();
const allDataAccess = hasPermission('all_data_access');
const months = useMemo(() => reportMonths(), []);
const [month, setMonth] = useState(currentMonth);
const [hierarchies, setHierarchies] = useState([]);
const [hierarchyError, setHierarchyError] = useState('');
const [selectedHier, setSelectedHier] = useState([]);
const [results, setResults] = useState({});
const hierarchyIds = useMemo(() => JSON.stringify(selectedHier), [selectedHier]);
useEffect(() => {
const loadHierarchies = async () => {
const params = { partition: HIERARCHY_PARTITION };
if (allDataAccess) params.hierarchy_id = 'root';
const response = await signedGet('ums', 'hierarchy/hierarchy_below_me', params, { timeout: REPORT_TIMEOUT });
if (!isSuccess(response)) {
setHierarchyError(response?.message || 'Unable to load hierarchy');
return;
}
const list = Array.isArray(response.data?.hierarchies) ? [...response.data.hierarchies] : [];
list.sort((a, b) => Number(b.level || 0) - Number(a.level || 0));
setHierarchies(list);
};
loadHierarchies();
}, [allDataAccess]);
const loadSections = useCallback((sections, params, isCancelled) => {
setResults((prev) => {
const next = { ...prev };
sections.forEach((section) => {
next[section.id] = { ...prev[section.id], loading: true, error: '' };
});
return next;
});
sections.forEach(async (section) => {
const response = await signedGet('reports', section.path, params, { timeout: REPORT_TIMEOUT });
if (isCancelled()) return;
const ok = isSuccess(response) || response?.body !== undefined;
setResults((prev) => ({
...prev,
[section.id]: ok
? { loading: false, error: '', table: section.build(reportData(response)) }
: { loading: false, error: response?.message || 'Unable to load report', table: EMPTY_TABLE },
}));
});
}, []);
useEffect(() => {
let cancelled = false;
loadSections(MONTH_SECTIONS, { month, hierarchy_ids: hierarchyIds }, () => cancelled);
return () => {
cancelled = true;
};
}, [month, hierarchyIds, loadSections]);
useEffect(() => {
let cancelled = false;
loadSections(TREND_SECTIONS, { hierarchy_ids: hierarchyIds }, () => cancelled);
return () => {
cancelled = true;
};
}, [hierarchyIds, loadSections]);
return (
<Container fluid>
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2">
<PageBreadcrumb title="MYDTx Dashboard" />
<div className="d-flex align-items-center gap-2">
<HierarchyFilter options={hierarchies} selected={selectedHier} onApply={setSelectedHier} />
<Form.Select value={month} onChange={(event) => setMonth(event.target.value)} style={{ width: 170 }}>
{months.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Form.Select>
</div>
</div>
{hierarchyError ? <div className="alert alert-warning">{hierarchyError}</div> : null}
{SECTIONS.map((section, index) => {
const result = results[section.id] || {};
return (
<ReportSection
key={section.id}
title={`${index + 1}. ${section.title(month)}`}
table={result.table || EMPTY_TABLE}
loading={result.loading ?? true}
error={result.error}
filename={section.filename}
stickyFirstColumn={section.sticky}
/>
);
})}
</Container>
);
}

View File

@ -0,0 +1,73 @@
import { useMemo, useState } from 'react';
import { Button, Dropdown, Form } from 'react-bootstrap';
export default function HierarchyFilter({ options, selected, onApply, disabled }) {
const [show, setShow] = useState(false);
const [draft, setDraft] = useState(selected);
const [search, setSearch] = useState('');
const visible = useMemo(() => {
const term = search.trim().toLowerCase();
if (!term) return options;
return options.filter((option) => `${option.name} (${option.type})`.toLowerCase().includes(term));
}, [options, search]);
const toggleOpen = (next) => {
if (next) {
setDraft(selected);
setSearch('');
}
setShow(next);
};
const toggleOption = (id) => {
setDraft((prev) => (prev.includes(id) ? prev.filter((item) => item !== id) : [...prev, id]));
};
const apply = () => {
onApply(draft);
setShow(false);
};
return (
<Dropdown show={show} onToggle={toggleOpen} autoClose="outside" align="end">
<Dropdown.Toggle variant="outline-secondary" className="gennova-hier-toggle" disabled={disabled}>
{selected.length ? `Hierarchy (${selected.length})` : 'Hierarchy'}
</Dropdown.Toggle>
<Dropdown.Menu className="p-3 gennova-hier-menu">
<Form.Control
size="sm"
className="mb-2"
placeholder="Search hierarchy..."
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<div className="gennova-hier-options">
{visible.length ? (
visible.map((option) => (
<Form.Check
key={option.id}
id={`gennova-hier-${option.id}`}
type="checkbox"
label={`${option.name} (${option.type})`}
checked={draft.includes(option.id)}
onChange={() => toggleOption(option.id)}
/>
))
) : (
<div className="text-muted small py-2">No hierarchy found</div>
)}
</div>
<hr className="my-2" />
<div className="d-flex justify-content-between">
<Button size="sm" variant="light" onClick={() => setDraft([])}>
Clear
</Button>
<Button size="sm" variant="primary" onClick={apply}>
Apply
</Button>
</div>
</Dropdown.Menu>
</Dropdown>
);
}

View File

@ -0,0 +1,104 @@
import { useState } from 'react';
import { Button, Card, Spinner, Table } from 'react-bootstrap';
import clsx from 'clsx';
import { exportTableToXlsx } from './exportXlsx';
import { formatCell, headerRows, rowValue } from './helpers';
export default function ReportSection({ title, table, loading, error, filename, stickyFirstColumn }) {
const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState('');
const { columns, rows, total, bordered } = table;
const headers = headerRows(columns);
const handleExport = async () => {
setExporting(true);
setExportError('');
try {
await exportTableToXlsx(table, filename);
} catch (err) {
setExportError(err.message || 'Unable to export report');
} finally {
setExporting(false);
}
};
const cellClass = (index) =>
clsx(stickyFirstColumn && index === 0 && 'sticky-first-col', columns[index]?.className);
return (
<Card className="mb-3">
<Card.Header className="d-flex justify-content-between align-items-center">
<h5 className="card-title mb-0">{title}</h5>
<Button size="sm" variant="primary" onClick={handleExport} disabled={loading || exporting || !columns.length}>
{exporting ? 'Exporting...' : 'Export'}
</Button>
</Card.Header>
<Card.Body className="position-relative">
{error ? <div className="alert alert-danger mb-2">{error}</div> : null}
{exportError ? <div className="alert alert-danger mb-2">{exportError}</div> : null}
{loading ? (
<div className="gennova-report-loading">
<Spinner animation="border" />
</div>
) : null}
{columns.length ? (
<div className="gennova-report-scroll">
<Table
className={clsx(
'table-centered table-nowrap mb-0 gennova-report-table',
bordered ? 'transplant-summary-table' : 'table-borderless',
)}
>
<thead className="table-light">
{headers.map((cells, rowIndex) => (
<tr key={rowIndex}>
{cells.map((cell) => (
<th
key={`${rowIndex}-${cell.column}`}
colSpan={cell.colSpan}
rowSpan={cell.rowSpan}
className={clsx(cellClass(cell.column), cell.colSpan > 1 && 'text-center')}
>
{cell.label}
</th>
))}
</tr>
))}
</thead>
<tbody>
{rows.length ? (
rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{columns.map((column, index) => (
<td key={index} className={cellClass(index)}>
{formatCell(rowValue(row, column), column.format)}
</td>
))}
</tr>
))
) : (
<tr>
<td colSpan={columns.length} className="text-center text-muted py-3">
No data
</td>
</tr>
)}
{total ? (
<tr className="table-secondary fw-bold">
{total.map((value, index) => (
<td key={index} className={cellClass(index)}>
{formatCell(value, columns[index]?.format)}
</td>
))}
</tr>
) : null}
</tbody>
</Table>
</div>
) : (
<div className="text-center text-muted py-3">{loading ? 'Loading...' : 'No data'}</div>
)}
</Card.Body>
</Card>
);
}

View File

@ -0,0 +1,48 @@
import { excelValue, headerRows, rowValue } from './helpers';
export async function exportTableToXlsx(table, filename) {
const { default: ExcelJS } = await import('exceljs');
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Report');
const { columns, rows, total } = table;
const headers = headerRows(columns);
headers.forEach((cells, rowIndex) => {
const excelRow = rowIndex + 1;
cells.forEach((cell) => {
const col = cell.column + 1;
sheet.getCell(excelRow, col).value = cell.label;
if (cell.colSpan > 1 || cell.rowSpan > 1) {
sheet.mergeCells(excelRow, col, excelRow + cell.rowSpan - 1, col + cell.colSpan - 1);
}
});
});
for (let r = 1; r <= headers.length; r += 1) {
const row = sheet.getRow(r);
row.font = { bold: true };
row.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
}
rows.forEach((row) => {
sheet.addRow(columns.map((column) => excelValue(rowValue(row, column))));
});
if (total) {
sheet.addRow(total.map(excelValue)).font = { bold: true };
}
columns.forEach((column, index) => {
sheet.getColumn(index + 1).width = Math.min(Math.max(String(column.label).length + 2, 12), 40);
});
const buffer = await workbook.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
const anchor = document.createElement('a');
anchor.href = URL.createObjectURL(blob);
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(anchor.href);
}

View File

@ -0,0 +1,363 @@
export const REPORT_TIMEOUT = 120000;
export const HIERARCHY_PARTITION = 'rapport-gennovadc';
const FIRST_REPORT_MONTH = new Date(2025, 6, 1);
const MONTH_ABBR = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
export function monthName(date) {
return date.toLocaleString('en-US', { month: 'long', year: 'numeric' });
}
export function currentMonth() {
return monthName(new Date());
}
export function reportMonths() {
const months = [];
const end = new Date();
end.setDate(1);
const cursor = new Date(FIRST_REPORT_MONTH);
while (cursor <= end) {
months.push(monthName(cursor));
cursor.setMonth(cursor.getMonth() + 1);
}
return months.reverse();
}
export function isNumeric(value) {
if (typeof value === 'number') return Number.isFinite(value);
if (typeof value !== 'string' || value.trim() === '') return false;
return Number.isFinite(Number(value));
}
function toNumber(value) {
return isNumeric(value) ? Number(value) : 0;
}
function numberFormat(value, decimals = 0) {
return Number(value).toLocaleString('en-US', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
});
}
export function formatCell(value, format = 'auto') {
if (value === null || value === undefined || value === '') return '';
if (!isNumeric(value)) return typeof value === 'object' ? '' : String(value);
const num = Number(value);
if (format === 'int') return numberFormat(num);
return Number.isInteger(num) ? numberFormat(num) : numberFormat(num, 2);
}
export function excelValue(value) {
if (value === null || value === undefined) return '';
if (isNumeric(value)) return Number(value);
return typeof value === 'object' ? '' : value;
}
function ucwords(text) {
return String(text).replace(/(^|\s)(\S)/g, (_, space, char) => space + char.toUpperCase());
}
function keyLabel(key) {
return ucwords(String(key).replace(/[_-]/g, ' '));
}
function percent(numerator, denominator) {
return denominator > 0 ? `${Math.round((numerator / denominator) * 100)}%` : '0%';
}
function asArray(value) {
return Array.isArray(value) ? value : [];
}
/** Lambda responses are sometimes wrapped as { body: "<json>" }. */
export function reportData(payload) {
if (!payload) return {};
if (payload.body !== undefined) {
let body = payload.body;
if (typeof body === 'string') {
try {
body = JSON.parse(body);
} catch {
return {};
}
}
return body?.data || {};
}
return payload.data || {};
}
function cellValue(row, key, nestedKey = null) {
const value = row && typeof row === 'object' ? row[key] : undefined;
if (nestedKey === null) return value ?? '';
return value && typeof value === 'object' ? (value[nestedKey] ?? '') : '';
}
function columnsFromFirstRow(items, format) {
if (!items.length) return [];
const keys = Object.keys(items[0]);
keys.sort((a, b) => (a === 'product_name' ? -1 : b === 'product_name' ? 1 : 0));
return keys.map((key) => ({ key, label: keyLabel(key), format }));
}
function sumColumns(items, keys) {
const totals = {};
keys.forEach((key) => {
totals[key] = items.reduce((sum, item) => sum + toNumber(item?.[key]), 0);
});
return totals;
}
export function buildMonthlySummary(data) {
const items = asArray(data.item);
const columns = columnsFromFirstRow(items, 'auto');
const totals = sumColumns(items, columns.map((col) => col.key).filter((key) => key !== 'product_name'));
const total = items.length
? columns.map((col, index) => {
if (index === 0) return 'Total';
if (col.key === 'percent_ach') return percent(totals.units_ach, totals.units_plan);
return totals[col.key] ?? 0;
})
: null;
return { columns, rows: items, total };
}
export function buildWeeklySummary(data) {
const items = asArray(data.item);
const columns = columnsFromFirstRow(items, 'int');
const totals = sumColumns(items, columns.map((col) => col.key).filter((key) => key !== 'product_name'));
const total = items.length
? columns.map((col, index) => {
if (index === 0) return 'Total';
if (col.key === 'percent_ach') return percent(totals.units_ach, totals.month_plan);
return totals[col.key] ?? 0;
})
: null;
return { columns, rows: items, total };
}
export function buildCenterSummary(data) {
const items = asArray(data.item);
const apiTotal = data.total && typeof data.total === 'object' ? data.total : null;
const columns = columnsFromFirstRow(items, 'int');
const total = items.length && apiTotal
? columns.map((col, index) => {
if (index === 0) return 'Total';
if (col.key === 'percent_ach') return percent(toNumber(apiTotal.centers_ach), toNumber(apiTotal.centers_plan));
return toNumber(apiTotal[col.key]);
})
: null;
return { columns, rows: items, total };
}
/** Current month first, then the previous 11 (matches the reports API rolling window). */
function rolling12MonthColumns() {
const now = new Date();
const months = [];
for (let i = 0; i < 12; i += 1) {
const date = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push({
key: `${MONTH_ABBR[date.getMonth()]}_${date.getFullYear()}`,
label: date.toLocaleString('en-US', { month: 'short', year: 'numeric' }),
});
}
return months;
}
function trendMonthKeyLabel(key) {
const match = /^([A-Z]{3})_(\d{4})$/.exec(key);
if (!match) return key;
const monthIndex = MONTH_ABBR.indexOf(match[1]);
if (monthIndex < 0) return key;
return new Date(Number(match[2]), monthIndex, 1).toLocaleString('en-US', { month: 'short', year: 'numeric' });
}
/** Use API months when present and year-aware; legacy keys like "JAN" fall back to a rolling 12 months. */
function trendMonthColumns(data) {
const months = asArray(data.months);
const firstKey = months[0]?.key || '';
if (firstKey && !/^[A-Z]{3}$/.test(firstKey)) return months;
return rolling12MonthColumns();
}
export function buildTrendSummary(data) {
const items = asArray(data.item);
const columns = [
{ key: 'product_name', label: 'Product Name', format: 'int' },
...trendMonthColumns(data)
.filter((month) => month?.key)
.map((month) => ({ key: month.key, label: month.label || trendMonthKeyLabel(month.key), format: 'int' })),
];
const apiTotal = data.total && typeof data.total === 'object' && Object.keys(data.total).length ? data.total : null;
const totals = apiTotal || sumColumns(items, columns.slice(1).map((col) => col.key));
const total = items.length
? columns.map((col, index) => (index === 0 ? 'Total' : toNumber(totals[col.key])))
: null;
return { columns, rows: items, total };
}
const WEEKS = ['Week-1', 'Week-2', 'Week-3', 'Week-4'];
export function buildTransplantPenetration(data) {
const items = asArray(data.transplant_penetration);
const flat = [
['Transplant Type', 'Transplant Type'],
['Monthly Expected no. of Transplant', 'Monthly Expected no. of Transplant'],
['Gennova Share Dennovo Plan', 'Gennova Share Denovo Plan'],
['Gennova Share Switch Plan', 'Gennova Share Switch Plan'],
['Gennova Share Graftsol Tx Plan', 'Gennova Share Graftsol Tx Plan'],
['Total Actual Transplants', 'Total Actual Transplants'],
];
const groups = [
['Gennova Share Dennovo Actual', 'Gennova Share Denovo Actual'],
['Gennova Share Switch Actual', 'Gennova Share Switch Actual'],
['Gennova Share Graftsol Tx Actual', 'Gennova Share Graftsol Tx Actual'],
];
const columns = [
...flat.map(([key, label]) => ({ key, label })),
...groups.flatMap(([key, group]) =>
WEEKS.map((week) => ({ key, nestedKey: week, label: week, group })),
),
];
return { columns, rows: items, total: null };
}
const TRANSPLANT_SUMMARY_FLAT = [
['Position Codes', 'Position Code'],
['Total Transplants Plan', 'Total Transplants Expected'],
['Total Transplants Actual', 'Total Transplants Actual'],
['Gennova Share Denovo Plan', 'Gennova Share Denovo Plan'],
['Gennova Share Denovo Actual', 'Gennova Share Denovo Actual'],
['Gennova Share Switch Plan', 'Gennova Share Switch Plan'],
['Gennova Share Switch Actual', 'Gennova Share Switch Actual'],
['Gennova Share Graftsol Tx Plan', 'Gennova Share Graftsol Tx Plan'],
['Gennova Share Graftsol Tx Actual', 'Gennova Share Graftsol Tx Actual'],
];
const TRANSPLANT_SUMMARY_GROUPS = [
{
name: 'Kidney Transplant',
columns: [
['Kidney Tx Actual', 'Kidney Tx Actual'],
['Kidney Gennova Share Denovo Actual', 'Gennova Share Denovo Actual'],
['Kidney Gennova Share Switch Actual', 'Gennova Share Switch Actual'],
['Kidney Gennova Share Graftsol Tx Actual', 'Gennova Share Graftsol Tx Actual'],
],
},
{
name: 'Liver Transplant',
columns: [
['Liver Tx Actual', 'Liver Tx Actual'],
['Liver Gennova Share Denovo Actual', 'Gennova Share Denovo Actual'],
['Liver Gennova Share Switch Actual', 'Gennova Share Switch Actual'],
['Liver Gennova Share Graftsol Tx Actual', 'Gennova Share Graftsol Tx Actual'],
],
},
{
name: 'Cardioplegia',
columns: [
['Cardioplegia Plan', 'Cardioplegia Expected'],
['Cardioplegia Actual', 'Cardioplegia Actual'],
['Gennova Share Graftsol Cardioplegia', 'Gennova Share Graftsol Cardioplegia'],
],
},
];
export function buildTransplantSummary(data) {
const items = asArray(data.transplant_summary ?? data.item);
const columns = [
...TRANSPLANT_SUMMARY_FLAT.map(([key, label], index) => ({
key,
label,
className: index === TRANSPLANT_SUMMARY_FLAT.length - 1 ? 'ts-section-end' : undefined,
})),
...TRANSPLANT_SUMMARY_GROUPS.flatMap((group) =>
group.columns.map(([key, label], index) => ({
key,
label,
group: group.name,
className: index === 0 ? 'ts-group-start' : undefined,
})),
),
];
return { columns, rows: items, total: null, bordered: true };
}
function activityLabel(text) {
return ucwords(String(text).replace(/_/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase());
}
function activityMonthTime(label) {
const match = /^([A-Za-z]{3})-(\d{2})$/.exec(label);
if (!match) return 0;
const monthIndex = MONTH_ABBR.indexOf(match[1].toUpperCase());
return monthIndex < 0 ? 0 : new Date(2000 + Number(match[2]), monthIndex, 1).getTime();
}
const ACTIVITY_HIDDEN = ['Position_id', 'Position_owner'];
const ACTIVITY_PREFERRED = ['Product Name', '8. Activity', 'Activity', 'Month Plan', 'Month Actl', 'Month Actual'];
export function buildActivitySummary(data) {
const items = asArray(data.activity_summary ?? data.item);
if (!items.length) return { columns: [], rows: [], total: null };
let flatCols = [];
const nestedCols = [];
Object.entries(items[0]).forEach(([key, value]) => {
if (ACTIVITY_HIDDEN.includes(key)) return;
if (value && typeof value === 'object') {
Object.keys(value).forEach((subKey) => {
nestedCols.push({ key, nestedKey: subKey, label: `${activityLabel(key)} - ${activityLabel(subKey)}` });
});
} else {
flatCols.push({ key, label: activityLabel(key) });
}
});
const ordered = [];
ACTIVITY_PREFERRED.forEach((label) => {
const index = flatCols.findIndex((col) => col.label === label);
if (index >= 0) {
ordered.push(flatCols[index]);
flatCols = flatCols.filter((_, i) => i !== index);
}
});
const isMonth = (col) => /^[A-Za-z]{3}-\d{2}$/.test(col.label);
const months = flatCols.filter(isMonth).sort((a, b) => activityMonthTime(b.label) - activityMonthTime(a.label));
const rest = flatCols.filter((col) => !isMonth(col));
return { columns: [...ordered, ...rest, ...months, ...nestedCols], rows: items, total: null };
}
export function rowValue(row, column) {
return cellValue(row, column.key, column.nestedKey ?? null);
}
/**
* Header rows for a table model. Columns sharing a `group` are merged under one
* top-level cell; ungrouped columns span both rows.
*/
export function headerRows(columns) {
const hasGroups = columns.some((col) => col.group);
if (!hasGroups) {
return [columns.map((col, index) => ({ label: col.label, colSpan: 1, rowSpan: 1, column: index }))];
}
const top = [];
const bottom = [];
columns.forEach((col, index) => {
if (!col.group) {
top.push({ label: col.label, colSpan: 1, rowSpan: 2, column: index });
return;
}
const last = top[top.length - 1];
if (last && last.group === col.group && last.column + last.colSpan === index) {
last.colSpan += 1;
} else {
top.push({ label: col.group, group: col.group, colSpan: 1, rowSpan: 1, column: index });
}
bottom.push({ label: col.label, colSpan: 1, rowSpan: 1, column: index });
});
return [top, bottom];
}

View File

@ -0,0 +1,129 @@
import { useEffect, useState } from 'react';
import { Button, Card, Col, Container, Form, Row, Table } from 'react-bootstrap';
import { TbPencil, TbTrash } from 'react-icons/tb';
import { laravelGet, laravelPost, isLaravelSuccess } from '@/api/laravel';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import { STATES } from './helpers';
const empty = { id: '', region: '', state: '', pincode: '', name: '' };
export default function NebDepotPage() {
const [items, setItems] = useState([]);
const [form, setForm] = useState(empty);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [saving, setSaving] = useState(false);
const load = async () => {
const response = await laravelGet('neblogistic/depots');
if (isLaravelSuccess(response)) setItems(response.data?.items || []);
else setError(response?.message || 'Unable to load depots');
};
useEffect(() => { load(); }, []);
const submit = async (event) => {
event.preventDefault();
setSaving(true);
setError('');
setMessage('');
const response = await laravelPost('neblogistic/depots', form);
setSaving(false);
if (!isLaravelSuccess(response)) {
setError(response?.message || 'Save failed');
return;
}
setMessage(response.message || 'Depot is saved');
setForm(empty);
load();
};
const remove = async (id) => {
if (!window.confirm('Are you sure you want to delete this record?')) return;
const response = await laravelPost('neblogistic/depots/delete', { id });
if (!isLaravelSuccess(response)) {
setError(response?.message || 'Delete failed');
return;
}
setMessage(response.message);
if (form.id === id) setForm(empty);
load();
};
return (
<Container fluid>
<PageBreadcrumb title="Add / Edit Depot" subtitle="Neb Logistics" />
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
<Card className="mb-3">
<Card.Body>
<Form onSubmit={submit}>
<div className="d-flex justify-content-between mb-3">
<h4 className="mb-0">Add / Edit Depot</h4>
<div className="d-flex gap-2">
<Button type="button" variant="dark" onClick={() => setForm(empty)}>Add New</Button>
<Button type="submit" variant="success" disabled={saving}>Submit</Button>
</div>
</div>
<Row>
<Col md={6} className="mb-3">
<Form.Label>Region</Form.Label>
<Form.Control required value={form.region} onChange={(e) => setForm((p) => ({ ...p, region: e.target.value }))} />
</Col>
<Col md={6} className="mb-3">
<Form.Label>State</Form.Label>
<Form.Select required value={form.state} onChange={(e) => setForm((p) => ({ ...p, state: e.target.value }))}>
<option value=""> -- Select -- </option>
{STATES.map((state) => <option key={state}>{state}</option>)}
</Form.Select>
</Col>
<Col md={6} className="mb-3">
<Form.Label>Pincode</Form.Label>
<Form.Control required value={form.pincode} onChange={(e) => setForm((p) => ({ ...p, pincode: e.target.value }))} />
</Col>
<Col md={6} className="mb-3">
<Form.Label>Name</Form.Label>
<Form.Control required value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
</Col>
</Row>
</Form>
</Card.Body>
</Card>
<Table bordered hover responsive>
<thead className="table-dark">
<tr>
<th>Action</th>
<th>Region</th>
<th>State</th>
<th>Pincode</th>
</tr>
</thead>
<tbody>
{items.length ? items.map((item) => (
<tr key={item.id}>
<td className="d-flex gap-1 justify-content-center">
<Button size="sm" variant="primary" title="Edit" onClick={() => setForm({
id: item.id,
region: item.region || '',
state: item.state || '',
pincode: item.pincode || '',
name: item.name || '',
})}>
<TbPencil />
</Button>
<Button size="sm" variant="danger" title="Delete" onClick={() => remove(item.id)}>
<TbTrash />
</Button>
</td>
<td>{item.region}</td>
<td>{item.state}</td>
<td>{item.pincode}</td>
</tr>
)) : (
<tr><td colSpan={4} className="text-center">No records found.</td></tr>
)}
</tbody>
</Table>
</Container>
);
}

View File

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

View File

@ -0,0 +1,146 @@
import { useEffect, useState } from 'react';
import { Button, Card, Col, Container, Form, Row, Table } from 'react-bootstrap';
import { TbPencil, TbTrash } from 'react-icons/tb';
import { laravelGet, laravelPost, isLaravelSuccess } from '@/api/laravel';
import PageBreadcrumb from '@/components/PageBreadcrumb';
const empty = { id: '', depot_id: '', emp_id_tm: '', tm_name: '', tm_email: '', hierarchy_id: '' };
export default function NebMappingPage() {
const [depots, setDepots] = useState([]);
const [items, setItems] = useState([]);
const [form, setForm] = useState(empty);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [saving, setSaving] = useState(false);
const load = async () => {
const [depotRes, mapRes] = await Promise.all([
laravelGet('neblogistic/depots'),
laravelGet('neblogistic/mappings'),
]);
if (isLaravelSuccess(depotRes)) setDepots(depotRes.data?.items || []);
if (isLaravelSuccess(mapRes)) setItems(mapRes.data?.items || []);
if (!isLaravelSuccess(depotRes) || !isLaravelSuccess(mapRes)) {
setError(depotRes?.message || mapRes?.message || 'Unable to load mappings');
}
};
useEffect(() => { load(); }, []);
const submit = async (event) => {
event.preventDefault();
setSaving(true);
setError('');
setMessage('');
const response = await laravelPost('neblogistic/mappings', form);
setSaving(false);
if (!isLaravelSuccess(response)) {
setError(response?.message || 'Save failed');
return;
}
setMessage(response.message || 'Successfully Saved !!!');
setForm(empty);
load();
};
const remove = async (id) => {
if (!window.confirm('Are you sure you want to delete this record?')) return;
const response = await laravelPost('neblogistic/mappings/delete', { id });
if (!isLaravelSuccess(response)) {
setError(response?.message || 'Delete failed');
return;
}
setMessage(response.message);
if (form.id === id) setForm(empty);
load();
};
return (
<Container fluid>
<PageBreadcrumb title="Depot & TM Mapping" subtitle="Neb Logistics" />
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
<Card className="mb-3">
<Card.Body>
<Form onSubmit={submit}>
<div className="d-flex justify-content-between mb-3">
<h4 className="mb-0">Add / Edit Mapping</h4>
<div className="d-flex gap-2">
<Button type="button" variant="dark" onClick={() => setForm(empty)}>Add New</Button>
<Button type="submit" variant="success" disabled={saving}>Submit</Button>
</div>
</div>
<Row>
<Col md={6} className="mb-3">
<Form.Label>Depot</Form.Label>
<Form.Select required value={form.depot_id} onChange={(e) => setForm((p) => ({ ...p, depot_id: e.target.value }))}>
<option value=""> -- Select -- </option>
{depots.map((depot) => (
<option key={depot.id} value={depot.id}>{depot.name || depot.region} ({depot.state})</option>
))}
</Form.Select>
</Col>
<Col md={6} className="mb-3">
<Form.Label>Emp ID of TM</Form.Label>
<Form.Control required value={form.emp_id_tm} onChange={(e) => setForm((p) => ({ ...p, emp_id_tm: e.target.value }))} />
</Col>
<Col md={6} className="mb-3">
<Form.Label>Name of TM</Form.Label>
<Form.Control required value={form.tm_name} onChange={(e) => setForm((p) => ({ ...p, tm_name: e.target.value }))} />
</Col>
<Col md={6} className="mb-3">
<Form.Label>Email of TM</Form.Label>
<Form.Control type="email" value={form.tm_email} onChange={(e) => setForm((p) => ({ ...p, tm_email: e.target.value }))} />
</Col>
<Col md={6} className="mb-3">
<Form.Label>Hierarchy ID</Form.Label>
<Form.Control value={form.hierarchy_id} onChange={(e) => setForm((p) => ({ ...p, hierarchy_id: e.target.value }))} />
</Col>
</Row>
</Form>
</Card.Body>
</Card>
<Table bordered hover responsive>
<thead>
<tr>
<th>Actions</th>
<th>Depot</th>
<th>TM Emp ID</th>
<th>TM Name</th>
<th>TM Email</th>
<th>Hierarchy ID</th>
</tr>
</thead>
<tbody>
{items.length ? items.map((item) => (
<tr key={item.id}>
<td className="d-flex gap-1">
<Button size="sm" variant="warning" onClick={() => setForm({
id: item.id,
depot_id: item.depot_id || '',
emp_id_tm: item.emp_id_tm || '',
tm_name: item.tm_name || '',
tm_email: item.tm_email || '',
hierarchy_id: item.hierarchy_id || '',
})}>
<TbPencil />
</Button>
<Button size="sm" variant="danger" onClick={() => remove(item.id)}>
<TbTrash />
</Button>
</td>
<td>{item.depot_name || item.depot_region || item.depot_id}</td>
<td>{item.emp_id_tm}</td>
<td>{item.tm_name}</td>
<td>{item.tm_email}</td>
<td>{item.hierarchy_id}</td>
</tr>
)) : (
<tr><td colSpan={6} className="text-center text-muted">No records found.</td></tr>
)}
</tbody>
</Table>
</Container>
);
}

View File

@ -0,0 +1,308 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { Button, Card, Col, Container, Form, Row, Spinner } from 'react-bootstrap';
import { laravelGet, laravelPost, isLaravelSuccess } from '@/api/laravel';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import { SPECIALITIES, STATUSES, displayValue, letterUrl } from './helpers';
function Field({ label, children }) {
return (
<Form.Group className="mb-3">
<Form.Label>{label}</Form.Label>
{children}
</Form.Group>
);
}
function ReadRow({ label, value }) {
return (
<p className="text-muted mb-2">
{label}: <strong>{displayValue(value)}</strong>
</p>
);
}
export default function NebReportDetail() {
const [params] = useSearchParams();
const navigate = useNavigate();
const id = params.get('id');
const mode = params.get('requesttype') === 'edit' ? 'edit' : 'view';
const filterQuery = useMemo(() => ({
state: params.get('state') || '',
from_date: params.get('from_date') || '',
to_date: params.get('to_date') || '',
status: params.get('status') || '',
}), [params]);
const [item, setItem] = useState(null);
const [doctors, setDoctors] = useState([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [form, setForm] = useState({});
const [status, setStatus] = useState('');
useEffect(() => {
let cancelled = false;
(async () => {
setLoading(true);
setError('');
const response = await laravelGet('neblogistic/show', { id });
if (cancelled) return;
if (!isLaravelSuccess(response)) {
setError(response?.message || 'Unable to load record');
setItem(null);
setLoading(false);
return;
}
const next = response.data?.item || {};
setItem(next);
setDoctors(response.data?.doctors || []);
setStatus(next.status || '');
setForm({
emp_id: next.emp_id || '',
employee_name: next.emp_name || '',
employee_phone: next.emp_phone || '',
employee_email: next.emp_email || '',
abm_name: next.abm_name || '',
abm_email: next.abm_email || '',
rbm_name: next.rbm_name || '',
dr_name: next.doctor_name || '',
doctor_mobile_number: next.doctor_phone || '',
speciality: next.doctor_speciality || '',
msl_code: next.msl_code || '',
type_of_nebulizer: next.type_of_nebulizer || 'Robust',
more_than_5_nebulizer: next.more_than_5_nebulizer || 'No',
no_of_nebulizer: next.no_of_nebulizer || '',
value_of_the_parcel: next.value_of_parcel || '',
clinic_hospital_home_name: next.clinic_hospital_name || '',
building_name: next.address_building || '',
address_type: next.address_type || '',
address_line_1: next.address_line_1 || '',
address_line_2: next.address_line_2 || '',
state: next.address_state || '',
city: next.address_city || '',
pin_code: next.address_pin_code || '',
team: next.team || '',
depot_name: next.depot_name || '',
rejected_reason: next.rejected_reason || '',
order_no: next.order_no || '',
invoice_no: next.invoice_no || '',
courier_name: next.courier_name || '',
lr_no: next.lr_no || '',
lr_date: next.lr_date ? String(next.lr_date).slice(0, 10) : '',
});
setLoading(false);
})();
return () => { cancelled = true; };
}, [id]);
const goNeighbor = async (direction) => {
const response = await laravelGet('neblogistic/neighbor', { id, direction, ...filterQuery });
if (!isLaravelSuccess(response)) {
setError(response?.message || 'No further entries available.');
return;
}
const next = new URLSearchParams({ ...filterQuery, id: response.data.id, requesttype: mode });
navigate(`/neblogisticreport/viewreportbyid?${next}`);
};
const save = async (payload) => {
setSaving(true);
setError('');
setMessage('');
const response = await laravelPost('neblogistic/update', { record_id: id, ...payload });
setSaving(false);
if (!isLaravelSuccess(response)) {
setError(response?.message || 'Save failed');
return;
}
setMessage(response.message || 'Data Saved');
};
if (loading) {
return <Container fluid className="py-5 text-center"><Spinner animation="border" /></Container>;
}
if (!item) {
return <Container fluid><div className="alert alert-danger">{error || 'Record not found'}</div></Container>;
}
const image = letterUrl(item.request_letter);
const canEdit = mode === 'edit';
return (
<Container fluid>
<PageBreadcrumb title={`${canEdit ? 'Edit' : 'View'} Neblogistic Report`} subtitle="Neb Logistics" />
{error ? <div className="alert alert-danger">{error}</div> : null}
{message ? <div className="alert alert-success">{message}</div> : null}
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
<div>
RequestID: <strong>{item.id}</strong> <span className="text-muted">|</span> Status:{' '}
<span className="badge bg-info">{item.status}</span>
</div>
<div className="d-flex flex-wrap gap-2">
<Form.Select style={{ width: 180 }} value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="">Select Status</option>
{STATUSES.map((value) => <option key={value} value={value}>{value}</option>)}
</Form.Select>
<Button size="sm" variant="dark" disabled={saving} onClick={() => save({ status, ...form })}>Change</Button>
<Button size="sm" variant="outline-dark" onClick={() => goNeighbor('prev')}>Prev</Button>
<Button size="sm" variant="outline-dark" onClick={() => goNeighbor('next')}>Next</Button>
<Button size="sm" variant="dark" as={Link} to="/neblogisticreports">Back</Button>
</div>
</div>
<Row className="g-4">
<Col md={6}>
<Card>
<Card.Body>
<h4 className="text-primary">Employee Details</h4>
{canEdit ? (
<>
<Field label="Emp ID of TM"><Form.Control value={form.emp_id} onChange={(e) => setForm((p) => ({ ...p, emp_id: e.target.value }))} /></Field>
<Field label="Name of TM"><Form.Control value={form.employee_name} onChange={(e) => setForm((p) => ({ ...p, employee_name: e.target.value }))} /></Field>
<Field label="TM Mobile Number"><Form.Control value={form.employee_phone} onChange={(e) => setForm((p) => ({ ...p, employee_phone: e.target.value }))} /></Field>
<Field label="TM Email ID"><Form.Control type="email" value={form.employee_email} onChange={(e) => setForm((p) => ({ ...p, employee_email: e.target.value }))} /></Field>
<Field label="ABM Name"><Form.Control value={form.abm_name} onChange={(e) => setForm((p) => ({ ...p, abm_name: e.target.value }))} /></Field>
<Field label="ABM Email"><Form.Control value={form.abm_email} onChange={(e) => setForm((p) => ({ ...p, abm_email: e.target.value }))} /></Field>
<Field label="RBM Name"><Form.Control value={form.rbm_name} onChange={(e) => setForm((p) => ({ ...p, rbm_name: e.target.value }))} /></Field>
</>
) : (
<>
<ReadRow label="Emp ID of TM" value={item.emp_id} />
<ReadRow label="Name of TM" value={item.emp_name} />
<ReadRow label="TM Mobile Number" value={item.emp_phone} />
<ReadRow label="TM Email ID" value={item.emp_email} />
<ReadRow label="ABM Name" value={item.abm_name} />
<ReadRow label="RBM Name" value={item.rbm_name} />
</>
)}
<h4 className="text-primary mt-4">Doctor Details</h4>
{canEdit ? (
<>
<Field label="Dr Name"><Form.Control value={form.dr_name} onChange={(e) => setForm((p) => ({ ...p, dr_name: e.target.value }))} /></Field>
<Field label="Doctor Mobile Number"><Form.Control value={form.doctor_mobile_number} onChange={(e) => setForm((p) => ({ ...p, doctor_mobile_number: e.target.value }))} /></Field>
<Field label="Speciality">
<Form.Select value={form.speciality} onChange={(e) => setForm((p) => ({ ...p, speciality: e.target.value }))}>
<option value=""> -- Select -- </option>
{Object.entries(SPECIALITIES).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</Form.Select>
</Field>
<Field label="MSL CODE of Dr"><Form.Control value={form.msl_code} onChange={(e) => setForm((p) => ({ ...p, msl_code: e.target.value }))} /></Field>
<Field label="Type of Nebulizer">
<Form.Select value={form.type_of_nebulizer} onChange={(e) => setForm((p) => ({ ...p, type_of_nebulizer: e.target.value }))}>
{['Robust', 'Mini', 'Others'].map((value) => <option key={value}>{value}</option>)}
</Form.Select>
</Field>
<Field label="Do you need more than 5 nebulizers?">
<Form.Select value={form.more_than_5_nebulizer} onChange={(e) => setForm((p) => ({ ...p, more_than_5_nebulizer: e.target.value }))}>
<option>Yes</option>
<option>No</option>
</Form.Select>
</Field>
<Field label="No of Nebulizer"><Form.Control value={form.no_of_nebulizer} onChange={(e) => setForm((p) => ({ ...p, no_of_nebulizer: e.target.value }))} /></Field>
<Field label="Value of the Parcel"><Form.Control value={form.value_of_the_parcel} onChange={(e) => setForm((p) => ({ ...p, value_of_the_parcel: e.target.value }))} /></Field>
</>
) : (
<>
<ReadRow label="Dr Name" value={item.doctor_name} />
<ReadRow label="Doctor Mobile Number" value={item.doctor_phone} />
<ReadRow label="Speciality" value={item.doctor_speciality} />
<ReadRow label="MSL CODE of Dr" value={item.msl_code} />
<ReadRow label="Type of Nebulizer" value={item.type_of_nebulizer} />
<ReadRow label="Do you need more than 5 nebulizers" value={item.more_than_5_nebulizer} />
<ReadRow label="No of Nebulizer" value={item.no_of_nebulizer} />
<ReadRow label="Value of the Parcel" value={item.value_of_parcel} />
<ReadRow label="Team" value={item.team} />
</>
)}
</Card.Body>
</Card>
</Col>
<Col md={6}>
{image ? (
<Card className="mb-4">
<Card.Body>
<h4 className="text-primary">Request Letter</h4>
<img src={image} alt="Request Letter" className="img-fluid rounded border mb-2" style={{ maxHeight: 300, objectFit: 'contain' }} />
<a href={image} download>Download existing</a>
</Card.Body>
</Card>
) : null}
<Card>
<Card.Body>
<h4 className="text-primary">Drop Details</h4>
{canEdit ? (
<>
<Field label="Clinic / Hospital / Home Name"><Form.Control value={form.clinic_hospital_home_name} onChange={(e) => setForm((p) => ({ ...p, clinic_hospital_home_name: e.target.value }))} /></Field>
<Field label="Building Name"><Form.Control value={form.building_name} onChange={(e) => setForm((p) => ({ ...p, building_name: e.target.value }))} /></Field>
<Field label="Address Type"><Form.Control value={form.address_type} onChange={(e) => setForm((p) => ({ ...p, address_type: e.target.value }))} /></Field>
<Field label="Address line 1"><Form.Control value={form.address_line_1} onChange={(e) => setForm((p) => ({ ...p, address_line_1: e.target.value }))} /></Field>
<Field label="Address line 2"><Form.Control value={form.address_line_2} onChange={(e) => setForm((p) => ({ ...p, address_line_2: e.target.value }))} /></Field>
<Field label="State"><Form.Control value={form.state} onChange={(e) => setForm((p) => ({ ...p, state: e.target.value }))} /></Field>
<Field label="City"><Form.Control value={form.city} onChange={(e) => setForm((p) => ({ ...p, city: e.target.value }))} /></Field>
<Field label="Pin Code"><Form.Control value={form.pin_code} onChange={(e) => setForm((p) => ({ ...p, pin_code: e.target.value }))} /></Field>
<Field label="Depot"><Form.Control value={form.depot_name} onChange={(e) => setForm((p) => ({ ...p, depot_name: e.target.value }))} /></Field>
<Field label="Team"><Form.Control value={form.team} onChange={(e) => setForm((p) => ({ ...p, team: e.target.value }))} /></Field>
<Button disabled={saving} onClick={() => save({ ...form, status })}>Save Details</Button>
</>
) : (
<>
<ReadRow label="Clinic / Hospital / Home Name" value={item.clinic_hospital_name} />
<ReadRow label="Building Name" value={item.address_building} />
<ReadRow label="Address Type" value={item.address_type} />
<ReadRow label="Address line 1" value={item.address_line_1} />
<ReadRow label="Address line 2" value={item.address_line_2} />
<ReadRow label="State" value={item.address_state} />
<ReadRow label="City" value={item.address_city} />
<ReadRow label="Pin Code" value={item.address_pin_code} />
</>
)}
</Card.Body>
</Card>
{['Dispatched', 'Delivered'].includes(item.status) || status === 'Dispatched' ? (
<Card className="mt-4">
<Card.Body>
<h4 className="text-primary">Dispatch / Delivery Details</h4>
{canEdit ? (
<>
<Field label="Order No"><Form.Control value={form.order_no} onChange={(e) => setForm((p) => ({ ...p, order_no: e.target.value }))} /></Field>
<Field label="Invoice Number"><Form.Control value={form.invoice_no} onChange={(e) => setForm((p) => ({ ...p, invoice_no: e.target.value }))} /></Field>
<Field label="Courier Name"><Form.Control value={form.courier_name} onChange={(e) => setForm((p) => ({ ...p, courier_name: e.target.value }))} /></Field>
<Field label="LR Number"><Form.Control value={form.lr_no} onChange={(e) => setForm((p) => ({ ...p, lr_no: e.target.value }))} /></Field>
<Field label="LR Date"><Form.Control type="date" value={form.lr_date} onChange={(e) => setForm((p) => ({ ...p, lr_date: e.target.value }))} /></Field>
</>
) : (
<>
<ReadRow label="Invoice Number" value={item.invoice_no} />
<ReadRow label="Courier Name" value={item.courier_name} />
<ReadRow label="LR Number" value={item.lr_no} />
<ReadRow label="LR Date" value={item.lr_date} />
</>
)}
</Card.Body>
</Card>
) : null}
{status === 'Rejected' && canEdit ? (
<Card className="mt-4">
<Card.Body>
<Field label="Rejected Reason"><Form.Control as="textarea" rows={3} value={form.rejected_reason} onChange={(e) => setForm((p) => ({ ...p, rejected_reason: e.target.value }))} /></Field>
</Card.Body>
</Card>
) : null}
</Col>
</Row>
{doctors.length ? (
<Card className="mt-4">
<Card.Body>
<h4 className="text-primary">Other requests with same MSL</h4>
{doctors.map((row) => (
<div key={row.id} className="small text-muted">{row.id} — {row.emp_name} — {row.status}</div>
))}
</Card.Body>
</Card>
) : null}
</Container>
);
}

View File

@ -0,0 +1,209 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { Button, Card, Col, Container, Form, Row, Spinner, Table } from 'react-bootstrap';
import { TbEye, TbPencil } from 'react-icons/tb';
import { laravelGet, laravelUrl, isLaravelSuccess } from '@/api/laravel';
import { getAccessToken } from '@/auth/storage';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import { formatReportDate, yesterdayInput } from './helpers';
export default function NebReportList() {
const [filters, setFilters] = useState({ depot: [], status: [], isDepotManager: false, isSuperDepotManager: false });
const [query, setQuery] = useState({ state: '', status: '', from_date: yesterdayInput(), to_date: yesterdayInput(), search: '' });
const [applied, setApplied] = useState({ state: '', status: '', from_date: yesterdayInput(), to_date: yesterdayInput(), search: '' });
const [items, setItems] = useState([]);
const [count, setCount] = useState(0);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(200);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
laravelGet('neblogistic/filters').then((response) => {
if (isLaravelSuccess(response)) setFilters(response.data || {});
});
}, []);
const load = useCallback(async () => {
setLoading(true);
setError('');
const response = await laravelGet('neblogistic/list', {
...applied,
start: page * pageSize,
length: pageSize,
});
if (!isLaravelSuccess(response)) {
setError(response?.message || 'Unable to load Neb Logistics reports');
setItems([]);
setCount(0);
setLoading(false);
return;
}
setItems(response.data?.items || []);
setCount(response.data?.count || 0);
setLoading(false);
}, [applied, page, pageSize]);
useEffect(() => {
load();
}, [load]);
const exportHref = useMemo(() => {
const params = new URLSearchParams({
state: applied.state || '',
status: applied.status || '',
from_date: applied.from_date || '',
to_date: applied.to_date || '',
access_token: getAccessToken() || '',
});
return `${laravelUrl('neblogistic/export')}?${params.toString()}`;
}, [applied]);
const hideDepot = filters.isDepotManager;
const statusOptions = filters.isDepotManager || filters.isSuperDepotManager
? (filters.status || []).filter((status) => status === 'Approved' || status === 'Dispatched')
: filters.status || [];
return (
<Container fluid>
<PageBreadcrumb title="Neblogistic Report Management" subtitle="Neb Logistics" />
<div className="d-flex justify-content-end gap-2 mb-3">
<Button as="a" href={exportHref} variant="dark">Export</Button>
<Button as={Link} to="/neblogisticreport/import" variant="dark">Import</Button>
{!filters.isDepotManager && !filters.isSuperDepotManager ? (
<Button as="a" href={`${exportHref}&approved_only=1`} variant="outline-dark">Export Old</Button>
) : null}
</div>
<Card className="mb-3">
<Card.Body>
<Row className="g-2 align-items-end">
{!hideDepot ? (
<Col lg={3} md={4}>
<Form.Label>Depot</Form.Label>
<Form.Select value={query.state} onChange={(e) => setQuery((prev) => ({ ...prev, state: e.target.value }))}>
<option value="">-- All Depots --</option>
{(filters.depot || []).map((depot) => (
<option key={depot} value={depot}>{depot}</option>
))}
</Form.Select>
</Col>
) : null}
<Col lg={3} md={4}>
<Form.Label>Status</Form.Label>
<Form.Select value={query.status} onChange={(e) => setQuery((prev) => ({ ...prev, status: e.target.value }))}>
{!filters.isDepotManager && !filters.isSuperDepotManager ? <option value="">-- All --</option> : null}
{statusOptions.map((status) => (
<option key={status} value={status}>{status}</option>
))}
</Form.Select>
</Col>
<Col lg={2} md={4}>
<Form.Label>From</Form.Label>
<Form.Control type="date" value={query.from_date} onChange={(e) => setQuery((prev) => ({ ...prev, from_date: e.target.value }))} />
</Col>
<Col lg={2} md={4}>
<Form.Label>To</Form.Label>
<Form.Control type="date" value={query.to_date} onChange={(e) => setQuery((prev) => ({ ...prev, to_date: e.target.value }))} />
</Col>
<Col lg={2} md={4}>
<Button
className="w-100"
onClick={() => {
setPage(0);
setApplied(query);
}}
>
Fetch
</Button>
</Col>
</Row>
</Card.Body>
</Card>
{error ? <div className="alert alert-danger">{error}</div> : null}
<Card>
<Card.Body>
<div className="d-flex justify-content-between align-items-center mb-2">
<Form.Control
style={{ maxWidth: 280 }}
placeholder="Search"
value={query.search}
onChange={(e) => setQuery((prev) => ({ ...prev, search: e.target.value }))}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setPage(0);
setApplied((prev) => ({ ...prev, search: query.search }));
}
}}
/>
<div className="text-muted small">{count} records</div>
</div>
{loading ? (
<div className="py-5 text-center"><Spinner animation="border" /></div>
) : (
<Table responsive hover>
<thead>
<tr>
<th>Id</th>
<th>Employee Number</th>
<th>Employee Name</th>
<th>Team</th>
<th>Depot Name</th>
<th>Doctor Name</th>
<th>MSL Code</th>
<th>Status</th>
<th>Request Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{items.length ? items.map((row) => {
const params = new URLSearchParams({
id: row.id,
state: applied.state || '',
from_date: applied.from_date || '',
to_date: applied.to_date || '',
status: applied.status || '',
});
return (
<tr key={row.id}>
<td>{row.id}</td>
<td>{row.emp_id}</td>
<td>{row.emp_name}</td>
<td>{row.team}</td>
<td>{row.depot_name}</td>
<td>{row.doctor_name}</td>
<td>{row.msl_code}</td>
<td>{row.status}</td>
<td>{formatReportDate(row.created_at)}</td>
<td className="d-flex gap-1">
{!filters.isDepotManager && !filters.isSuperDepotManager ? (
<Button as={Link} size="sm" variant="warning" to={`/neblogisticreport/viewreportbyid?${params}&requesttype=edit`} title="Edit">
<TbPencil />
</Button>
) : null}
<Button as={Link} size="sm" variant="info" to={`/neblogisticreport/viewreportbyid?${params}&requesttype=view`} title="View">
<TbEye />
</Button>
</td>
</tr>
);
}) : (
<tr><td colSpan={10} className="text-center text-muted">No records found.</td></tr>
)}
</tbody>
</Table>
)}
<div className="d-flex justify-content-between align-items-center">
<Form.Select style={{ width: 120 }} value={pageSize} onChange={(e) => { setPage(0); setPageSize(Number(e.target.value)); }}>
{[200, 350, 500, 750, 900].map((size) => <option key={size} value={size}>{size}</option>)}
</Form.Select>
<div className="d-flex gap-2">
<Button size="sm" variant="light" disabled={page === 0} onClick={() => setPage((prev) => prev - 1)}>Prev</Button>
<Button size="sm" variant="light" disabled={(page + 1) * pageSize >= count} onClick={() => setPage((prev) => prev + 1)}>Next</Button>
</div>
</div>
</Card.Body>
</Card>
</Container>
);
}

View File

@ -0,0 +1,79 @@
export const DEPOT_MANAGER = '1733912682-7c353bf8-c309-49b3-93e7-depotmanager';
export const SUPER_DEPOT_MANAGER = '1733912682-7c353bf8-c309-49b3-93e7-superdepotmanager';
export const STATES = [
'Andhra Pradesh',
'Andaman and Nicobar Islands',
'Arunachal Pradesh',
'Assam',
'Bihar',
'Chandigarh',
'Chhattisgarh',
'Dadar and Nagar Haveli',
'Daman and Diu',
'Delhi',
'Lakshadweep',
'Puducherry',
'Goa',
'Gujarat',
'Haryana',
'Himachal Pradesh',
'Jammu and Kashmir',
'Jharkhand',
'Karnataka',
'Kerala',
'Madhya Pradesh',
'Maharashtra',
'Manipur',
'Meghalaya',
'Mizoram',
'Nagaland',
'Odisha',
'Punjab',
'Rajasthan',
'Sikkim',
'Tamil Nadu',
'Telangana',
'Tripura',
'Uttar Pradesh',
'Uttarakhand',
'West Bengal',
];
export const SPECIALITIES = {
chest: 'Chest',
paedia: 'Paedia',
consulting_physician: 'Consulting Physician',
gp_mbbs: 'GP - MBBS',
gp_non_mbbs: 'GP - Non MBBS',
intensivist: 'Intensivist',
others: 'Others',
};
export const STATUSES = ['Requested', 'Approved', 'Rejected', 'Dispatched', 'Returned', 'Delivered'];
export function yesterdayInput() {
const date = new Date();
date.setDate(date.getDate() - 1);
return date.toISOString().slice(0, 10);
}
export function formatReportDate(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
return `${day}-${month}-${date.getFullYear()}`;
}
export function letterUrl(path) {
if (!path) return '';
return String(path).startsWith('http') ? path : `https://did94hhhcgq1d.cloudfront.net${path}`;
}
export function displayValue(value) {
if (value == null || value === '') return '';
if (Array.isArray(value)) return value.join(', ');
return String(value);
}

View File

@ -71,7 +71,14 @@ const emptyChild = {
channels: '',
};
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', {
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],
);

View File

@ -0,0 +1,185 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button, Card, Container, Form } from 'react-bootstrap';
import { signedGet, isSuccess } from '@/api/client';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import ReportGrid from './ReportGrid';
import { asList, brandField, extractFormRows, FORM_REPORT_TIMEOUT, REPORT_TIMEOUT } from './helpers';
const BASE_COLUMNS = [
{ key: 'user_name', title: 'User Name', width: 150, pinned: 'left' },
{ key: 'employee_code', title: 'Employee Code', width: 120 },
{ key: 'record_date', title: 'Submition Date', width: 120 },
{ key: 'contact_id', title: 'Doctor Name', width: 120 },
{ key: 'msl_code', title: 'MSL Code', width: 120 },
{ key: 'speciality', title: 'Speciality', width: 150 },
{ key: 'chemist_name', title: 'Chemist Name', width: 150 },
{ key: 'type_of_chemist', title: 'Chemist Type', width: 150 },
{ key: 'doctors_attached_to_chemist', title: 'Doctors Attached to Chemist', width: 180 },
];
function flattenFormRows(source) {
const brandMap = {};
const rows = (source || []).map((row) => {
const flat = { ...row };
asList(row.brands).forEach((brand) => {
const brandName = brand?.brand_name;
if (!brandName) return;
if (!brandMap[brandName]) brandMap[brandName] = new Set();
flat[brandField(brandName, 'cipla_qty')] = brand.cipla_qty;
flat[brandField(brandName, 'cipla_val')] = brand.cipla_value;
asList(brand.competitors).forEach((competitor) => {
const competitorName = competitor?.competitor_name;
if (!competitorName) return;
brandMap[brandName].add(competitorName);
flat[brandField(brandName, `${competitorName}::qty`)] = competitor.competitors_qty;
flat[brandField(brandName, `${competitorName}::val`)] = competitor.competitors_value;
});
});
return flat;
});
return { rows, brandMap };
}
export default function RcpaFormWise() {
const gridRef = useRef(null);
const [forms, setForms] = useState([]);
const [formId, setFormId] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [rows, setRows] = useState([]);
const [brandMap, setBrandMap] = useState({});
const [loadingForms, setLoadingForms] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
const loadForms = async () => {
setLoadingForms(true);
setError('');
try {
const response = await signedGet('form', 'formentity/dropdown', {}, { timeout: REPORT_TIMEOUT });
if (!isSuccess(response)) {
setError(response?.message || 'Unable to load forms');
setForms([]);
return;
}
const items = (response.data?.items || []).filter((item) => /rcpa/i.test(item.title || ''));
setForms(items);
} catch (err) {
setError(err.message || 'Unable to load forms');
setForms([]);
} finally {
setLoadingForms(false);
}
};
loadForms();
}, []);
const fetchReport = async () => {
setLoading(true);
setError('');
try {
const params = {};
if (formId) params.form_id = formId;
if (dateFrom && dateTo) params.date_range = `${dateFrom}_${dateTo}`;
const response = await signedGet('form', 'formentity/rcpa_transpose_report', params, {
timeout: FORM_REPORT_TIMEOUT,
});
if (!isSuccess(response)) {
setError(response?.message || 'Unable to load form wise report');
setRows([]);
setBrandMap({});
return;
}
const flattened = flattenFormRows(extractFormRows(response.data));
setRows(flattened.rows);
setBrandMap(flattened.brandMap);
} catch (err) {
setError(err.message || 'Unable to load form wise report');
setRows([]);
setBrandMap({});
} finally {
setLoading(false);
}
};
const columns = useMemo(() => {
const brandColumns = Object.keys(brandMap)
.sort()
.map((brandName) => {
const competitors = Array.from(brandMap[brandName] || []).sort();
return {
title: brandName,
children: [
{ key: brandField(brandName, 'cipla_qty'), title: 'Cipla Qty', width: 100, type: 'numericColumn' },
{ key: brandField(brandName, 'cipla_val'), title: 'Cipla Value', width: 100, type: 'numericColumn' },
...competitors.map((competitorName) => ({
title: competitorName,
children: [
{ key: brandField(brandName, `${competitorName}::qty`), title: 'Qty', width: 100, type: 'numericColumn' },
{ key: brandField(brandName, `${competitorName}::val`), title: 'Value', width: 100, type: 'numericColumn' },
],
})),
],
};
});
return [...BASE_COLUMNS, ...brandColumns];
}, [brandMap]);
return (
<Container fluid>
<PageBreadcrumb title="Form Wise RCPA Report" />
{error ? <div className="alert alert-danger">{error}</div> : null}
<Card className="mb-3">
<Card.Body className="d-flex flex-nowrap align-items-end justify-content-between gap-2">
<Form
className="d-flex flex-wrap align-items-end gap-2 rcpa-report-filters"
onSubmit={(event) => {
event.preventDefault();
fetchReport();
}}
>
<div>
<Form.Label className="mb-1">Form</Form.Label>
<Form.Select
value={formId}
onChange={(event) => setFormId(event.target.value)}
disabled={loadingForms}
style={{ minWidth: 220 }}
>
<option value="">All Forms</option>
{forms.map((form) => (
<option key={form.id} value={form.id}>
{form.title || form.id}
</option>
))}
</Form.Select>
</div>
<div>
<Form.Label className="mb-1">From</Form.Label>
<Form.Control type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} />
</div>
<div>
<Form.Label className="mb-1">To</Form.Label>
<Form.Control type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} />
</div>
<Button type="submit" variant="info" disabled={loading || loadingForms}>
{loading ? 'Fetching...' : 'Fetch'}
</Button>
</Form>
<Button size="sm" variant="primary" className="fw-bold flex-shrink-0 mb-1" onClick={() => gridRef.current?.exportCsv()} disabled={loading}>
Export to Excel
</Button>
</Card.Body>
</Card>
<ReportGrid
ref={gridRef}
columns={columns}
rows={rows}
loading={loading}
filename="rcpa-formwise.csv"
emptyDescription="Choose a form and date range, then click Fetch"
/>
</Container>
);
}

View File

@ -0,0 +1,128 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button, Card, Container, Form } from 'react-bootstrap';
import { signedGet, isSuccess } from '@/api/client';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import ReportGrid from './ReportGrid';
import {
asList,
DEFAULT_SKU,
extractAgg,
extractSkus,
monthFieldKey,
monthLabel,
REPORT_TIMEOUT,
} from './helpers';
export default function RcpaMonthWise() {
const gridRef = useRef(null);
const [skus, setSkus] = useState([]);
const [skuId, setSkuId] = useState('');
const [months, setMonths] = useState([]);
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const loadSkus = async () => {
try {
const response = await signedGet('rcpa', 'sku/list', {}, { timeout: REPORT_TIMEOUT });
if (!isSuccess(response)) {
setError(response?.message || 'Unable to load SKUs');
setSkus([]);
setSkuId(DEFAULT_SKU);
return;
}
const list = extractSkus(response.data);
setSkus(list);
setSkuId(list[0]?.id || DEFAULT_SKU);
} catch (err) {
setError(err.message || 'Unable to load SKUs');
setSkuId(DEFAULT_SKU);
}
};
loadSkus();
}, []);
useEffect(() => {
if (!skuId) return;
const load = async () => {
setLoading(true);
setError('');
try {
const response = await signedGet('rcpa', 'sku/monthwise', { sku: skuId }, { timeout: REPORT_TIMEOUT });
if (!isSuccess(response)) {
setError(response?.message || 'Unable to load month wise report');
setMonths([]);
setRows([]);
return;
}
setMonths(asList(response.data?.months));
setRows(extractAgg(response.data));
} catch (err) {
setError(err.message || 'Unable to load month wise report');
setMonths([]);
setRows([]);
} finally {
setLoading(false);
}
};
load();
}, [skuId]);
const columns = useMemo(
() => [
{ key: 'sku', title: 'Brand' },
{ key: 'competitor', title: 'Competitor' },
...months.map((month) => {
const suffix = monthFieldKey(month);
return {
title: monthLabel(month),
children: [
{ key: `quantity_${suffix}`, title: 'Quantity' },
{ key: `business_${suffix}`, title: 'Business' },
],
};
}),
],
[months],
);
return (
<Container fluid>
<PageBreadcrumb title="Month Wise RCPA Report" />
{error ? <div className="alert alert-danger">{error}</div> : null}
<Card className="mb-3">
<Card.Body className="d-flex flex-nowrap align-items-center justify-content-between gap-2">
<div className="d-flex flex-nowrap align-items-center gap-2 rcpa-report-filters">
<Form.Label className="mb-0">SKU</Form.Label>
<Form.Select
value={skuId}
onChange={(event) => setSkuId(event.target.value)}
style={{ maxWidth: 280 }}
disabled={!skus.length && !skuId}
>
{!skus.length ? <option value={skuId || DEFAULT_SKU}>{skuId || DEFAULT_SKU}</option> : null}
{skus.map((sku) => (
<option key={sku.id} value={sku.id}>
{sku.title || sku.id}
</option>
))}
</Form.Select>
</div>
<Button size="sm" variant="primary" className="fw-bold flex-shrink-0" onClick={() => gridRef.current?.exportCsv()} disabled={loading}>
Export to Excel
</Button>
</Card.Body>
</Card>
<ReportGrid
ref={gridRef}
columns={columns}
rows={rows}
loading={loading}
filename="rcpa-monthwise.csv"
emptyDescription="No month wise RCPA data for the selected SKU"
highlightBase
/>
</Container>
);
}

View File

@ -0,0 +1,73 @@
import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
import { AgGridReact } from 'ag-grid-react';
import { Card, Spinner } from 'react-bootstrap';
import { toColumnDefs } from './helpers';
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-alpine.css';
const DEFAULT_COL_DEF = {
resizable: true,
sortable: true,
filter: 'agTextColumnFilter',
floatingFilter: true,
width: 170,
};
const ReportGrid = forwardRef(function ReportGrid(
{
columns,
rows,
loading,
filename = 'rcpa-report.csv',
emptyDescription = 'No report data to show',
highlightBase = false,
},
ref,
) {
const gridRef = useRef(null);
const columnDefs = useMemo(() => toColumnDefs(columns), [columns]);
const overlayNoRowsTemplate = `<span class="ag-overlay-no-rows-center">${emptyDescription}</span>`;
const defaultColDef = useMemo(() => {
if (!highlightBase) return DEFAULT_COL_DEF;
return {
...DEFAULT_COL_DEF,
cellClassRules: {
'header-cell': (params) => params.data?.base === true,
},
};
}, [highlightBase]);
useImperativeHandle(ref, () => ({
exportCsv: () => {
gridRef.current?.api?.exportDataAsCsv({ fileName: filename });
},
}), [filename]);
return (
<Card>
<Card.Body className="position-relative">
{loading ? (
<div className="rcpa-report-loading">
<Spinner animation="border" />
</div>
) : null}
<div id="myGrid" className="ag-theme-alpine rcpa-report-grid">
<AgGridReact
ref={gridRef}
columnDefs={columnDefs}
rowData={rows || []}
defaultColDef={defaultColDef}
pagination
paginationPageSize={50}
paginationPageSizeSelector={false}
domLayout="normal"
overlayNoRowsTemplate={overlayNoRowsTemplate}
suppressCellFocus
/>
</div>
</Card.Body>
</Card>
);
});
export default ReportGrid;

View File

@ -0,0 +1,107 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button, Card, Container, Form } from 'react-bootstrap';
import { signedGet, isSuccess } from '@/api/client';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import ReportGrid from './ReportGrid';
import { extractAgg, extractSkus, rcpaMonthsFrom, REPORT_TIMEOUT, skuCompetitors } from './helpers';
const IDENTITY_COLUMNS = [
{ key: 'user_name', title: 'TM Name' },
{ key: 'employee_code', title: 'Employee Code' },
{ key: 'email', title: 'Email' },
{ key: 'no_of_contacts', title: 'No of Chemist' },
];
export default function RcpaTmWise() {
const months = useMemo(() => rcpaMonthsFrom(), []);
const gridRef = useRef(null);
const [month, setMonth] = useState(months[0] || '');
const [skus, setSkus] = useState([]);
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
setLoading(true);
setError('');
try {
const [listRes, reportRes] = await Promise.all([
signedGet('rcpa', 'sku/list', {}, { timeout: REPORT_TIMEOUT }),
signedGet('rcpa', 'sku/tmwise', month ? { month } : {}, { timeout: REPORT_TIMEOUT }),
]);
if (!isSuccess(listRes)) {
setError(listRes?.message || 'Unable to load SKUs');
setSkus([]);
} else {
setSkus(extractSkus(listRes.data));
}
if (!isSuccess(reportRes)) {
setError((prev) => prev || reportRes?.message || 'Unable to load TM wise report');
setRows([]);
} else {
setRows(extractAgg(reportRes.data));
}
} catch (err) {
setError(err.message || 'Unable to load TM wise report');
setRows([]);
} finally {
setLoading(false);
}
};
load();
}, [month]);
const columns = useMemo(
() => [
...IDENTITY_COLUMNS,
...skus.map((sku) => ({
title: sku.title || sku.id,
children: [
{ key: `quantity_${sku.id}`, title: sku.title || sku.id },
...skuCompetitors(sku).map((competitor) => ({
key: `quantity_${sku.id}_${competitor.id}`,
title: competitor.title || competitor.id,
})),
],
})),
],
[skus],
);
return (
<Container fluid>
<PageBreadcrumb title="TM Wise RCPA Report" />
{error ? <div className="alert alert-danger">{error}</div> : null}
<Card className="mb-3">
<Card.Body className="d-flex flex-nowrap align-items-center justify-content-between gap-2">
<div className="d-flex flex-nowrap align-items-center gap-2 rcpa-report-filters">
<Form.Label className="mb-0">Month</Form.Label>
<Form.Select
value={month}
onChange={(event) => setMonth(event.target.value)}
style={{ maxWidth: 220 }}
>
{months.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</Form.Select>
</div>
<Button size="sm" variant="primary" className="fw-bold flex-shrink-0" onClick={() => gridRef.current?.exportCsv()} disabled={loading}>
Export to Excel
</Button>
</Card.Body>
</Card>
<ReportGrid
ref={gridRef}
columns={columns}
rows={rows}
loading={loading}
filename="rcpa-tmwise.csv"
emptyDescription="No TM wise RCPA data for the selected month"
/>
</Container>
);
}

View File

@ -0,0 +1,89 @@
export const REPORT_TIMEOUT = 180000;
export const FORM_REPORT_TIMEOUT = 300000;
export const MONTH_START = 'September 2024';
export const DEFAULT_SKU = 'sku1';
export function asList(value) {
if (Array.isArray(value)) return value;
if (value && typeof value === 'object') return Object.values(value);
return [];
}
export function extractSkus(data) {
if (Array.isArray(data?.skus)) return data.skus;
if (Array.isArray(data?.items)) return data.items;
return asList(data).filter((item) => item && (item.id || item.title));
}
export function extractAgg(data) {
if (Array.isArray(data?.agg)) return data.agg;
if (Array.isArray(data?.chemists)) return data.chemists;
if (Array.isArray(data?.items)) return data.items;
if (Array.isArray(data)) return data;
return [];
}
export function extractFormRows(data) {
if (Array.isArray(data?.Result)) return data.Result;
if (Array.isArray(data?.result)) return data.result;
if (Array.isArray(data?.items)) return data.items;
if (Array.isArray(data?.response)) return data.response;
if (Array.isArray(data)) return data;
return [];
}
export function rcpaMonthsFrom(start = MONTH_START) {
const months = [];
const now = new Date();
const cursor = new Date(start);
if (Number.isNaN(cursor.getTime())) return months;
cursor.setDate(1);
cursor.setHours(0, 0, 0, 0);
while (cursor <= now) {
months.push(cursor.toLocaleString('en-US', { month: 'long', year: 'numeric' }));
cursor.setMonth(cursor.getMonth() + 1);
}
return months;
}
export function monthFieldKey(month) {
if (month == null) return '';
if (typeof month === 'object') {
return String(month.timestamp ?? month.ts ?? month.month_text ?? '');
}
return String(month);
}
export function monthLabel(month) {
if (month == null) return '';
if (typeof month === 'object') return String(month.month_text || month.label || month.timestamp || '');
return String(month);
}
export function toColumnDefs(columns) {
return (columns || []).map((column) => {
const def = {
headerName: column.title || column.headerName || '',
width: column.width || 170,
};
if (column.pinned) def.pinned = column.pinned;
if (column.type) def.type = column.type;
if (column.children?.length) {
def.children = toColumnDefs(column.children);
return def;
}
if (column.key || column.field) def.field = column.key || column.field;
if (typeof column.valueGetter === 'function') {
def.valueGetter = (params) => column.valueGetter(params.data);
}
return def;
});
}
export function skuCompetitors(sku) {
return asList(sku?.competitors || sku?.competitor);
}
export function brandField(brandName, suffix) {
return `${brandName}::${suffix}`;
}

View File

@ -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({
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'),
},
alias: { '@': resolve(__dirname, 'src') },
},
server: {
port: 5173,
proxy,
proxy: {
...laravelProxy,
...(env.VITE_API_MODE === 'local' ? localProxy : productionProxy),
},
},
};
});