Latest changes till 23 Sep 2026

This commit is contained in:
Amey Bhagwat 2026-09-23 12:44:51 +05:30
parent 41dd41b304
commit b6275d214a
57 changed files with 2997 additions and 682 deletions

View File

@ -103,7 +103,10 @@ async function request(method, service, path, data = {}, { signed = true, timeou
return {
statusCode: 0,
status: 'error',
message: error.message === 'Network Error' ? 'Network Error: the report API was blocked or unreachable.' : error.message || 'Network Error',
message:
error.message === 'Network Error'
? 'Network Error: the API was blocked or unreachable.'
: error.message || 'Network Error',
};
}
}

View File

@ -10,6 +10,18 @@
}
}
.entity-list-actions {
.entity-list-actions-month {
width: auto;
min-width: 150px;
height: $input-height;
padding-top: $btn-padding-y;
padding-bottom: $btn-padding-y;
font-size: $btn-font-size;
line-height: $btn-line-height;
}
}
// Form Control Small
.form-control-sm {
line-height: normal;

View File

@ -1,5 +1,5 @@
.rapport-logo {
height: 28px !important;
height: 40px !important;
width: auto !important;
max-width: 168px;
object-fit: contain;
@ -79,6 +79,10 @@
line-height: 1.2;
cursor: pointer;
font-family: inherit;
text-decoration: none;
}
.mlb-pill.is-active {
pointer-events: none;
}
.mlb-pill.is-active,
.mlb-pill:hover {
@ -207,12 +211,306 @@
padding: 48px 12px;
}
.metric-report-page .breadcrumb-item .btn-link {
text-decoration: none;
.mlb-dashboard {
.mlb-toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin: 0 0 16px;
}
.mlb-pills {
margin: 0;
flex: 1;
justify-content: flex-start;
}
.mlb-period {
display: flex;
align-items: center;
gap: 8px;
min-width: 220px;
}
.mlb-period label {
margin: 0;
white-space: nowrap;
color: var(--bs-secondary-color);
font-size: 13px;
font-weight: 600;
}
.mlb-period select {
min-width: 180px;
}
}
.btn-xs {
padding: 0.2rem 0.6rem;
font-size: 0.75rem;
border-radius: 0.15rem;
}
.btn-rounded {
border-radius: 2em;
}
.metric-report-page {
.button-list {
margin-left: -8px;
margin-bottom: -12px;
.btn {
margin-bottom: 12px;
margin-left: 8px;
}
}
.custom-date-go {
cursor: pointer;
}
.metric-report-actions {
display: inline-flex;
align-items: stretch;
justify-content: flex-end;
gap: 0.25rem;
flex-wrap: wrap;
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0;
line-height: 1.2;
}
}
.metric-report-filter {
padding-left: 0.45rem;
padding-right: 0.45rem;
svg {
width: 0.95em;
height: 0.95em;
}
}
.level-filter {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
flex-wrap: wrap;
text-align: start;
}
.level-filter-label,
.level-filter-btn {
color: var(--#{$prefix}secondary-color);
font-size: 15px;
font-weight: 500;
line-height: 1;
}
.level-filter-btn {
appearance: none;
background: transparent;
border: 0;
width: 2rem;
height: 2rem;
padding: 0;
border-radius: 50%;
cursor: pointer;
font-family: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
}
.level-filter-btn:hover:not(.is-active) {
background: var(--#{$prefix}tertiary-bg);
}
.level-filter-btn.is-active {
background: var(--#{$prefix}primary-bg-subtle);
color: var(--#{$prefix}primary);
}
.team-trail {
display: flex;
align-items: center;
justify-content: flex-start;
flex-wrap: wrap;
gap: 6px;
list-style: none;
padding: 0;
margin: 0;
text-align: start;
}
.team-trail-item {
display: inline-flex;
align-items: center;
gap: 6px;
}
.team-trail-sep {
color: var(--#{$prefix}secondary-color);
flex-shrink: 0;
}
.team-trail button,
.team-trail span {
background: none;
border: 0;
padding: 0;
font: inherit;
color: var(--#{$prefix}secondary-color);
}
.team-trail button {
cursor: pointer;
}
.team-trail button:hover {
color: var(--#{$prefix}primary);
}
.team-trail .is-current {
color: var(--#{$prefix}primary);
}
.rank-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
font-weight: $font-weight-semibold;
font-size: 0.8125rem;
background: var(--#{$prefix}light);
color: var(--#{$prefix}dark);
}
.rank-badge.rank-1 {
background: $warning;
color: $white;
box-shadow: $box-shadow;
}
.rank-badge.rank-2 {
background: $gray-400;
color: $white;
box-shadow: $box-shadow;
}
.rank-badge.rank-3 {
background: $orange;
color: $white;
box-shadow: $box-shadow;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 2rem 1rem;
}
.empty-state.is-sm {
padding: 1.25rem 0.75rem;
}
.empty-state-svg {
width: 7rem;
height: 7rem;
margin-bottom: 0.75rem;
}
.empty-state.is-sm .empty-state-svg {
width: 4.75rem;
height: 4.75rem;
}
.empty-state-doc {
fill: var(--#{$prefix}gray-200);
stroke: var(--#{$prefix}gray-400);
}
.empty-state-clip {
fill: var(--#{$prefix}gray-300);
stroke: var(--#{$prefix}gray-400);
}
.empty-state-line {
stroke: var(--#{$prefix}gray-400);
}
.empty-state-glass {
fill: var(--#{$prefix}gray-100);
stroke: var(--#{$prefix}gray-500);
}
.empty-state-handle {
stroke: var(--#{$prefix}gray-500);
}
.empty-state-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--#{$prefix}emphasis-color);
}
.empty-state-copy {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: var(--#{$prefix}secondary-color);
}
.empty-state-row > td {
white-space: normal !important;
}
.action-card {
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
height: 100%;
padding: 1.25rem 1rem;
}
.action-card-icon {
width: 5.5rem;
height: 5.5rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
}
}
.action-card-title {
margin: 0.85rem 0 0;
width: 100%;
text-align: center;
min-height: 2.5em;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.quick-action-modal .modal-content {

View File

@ -31,3 +31,187 @@
}
}
}
// Dropify-style preview used by S3FileField (image + file uploads)
.s3-file-field-wrap {
min-width: 0;
max-width: 100%;
}
.s3-file-field {
position: relative;
display: block;
overflow: hidden;
width: 100%;
max-width: 100%;
min-width: 0;
height: 200px;
padding: 0.25rem;
cursor: pointer;
border: 2px dashed var(--#{$prefix}border-color);
border-radius: 6px;
background-color: var(--#{$prefix}body-bg);
color: var(--#{$prefix}secondary-color);
&:hover,
&.is-dragover {
background-image: linear-gradient(
-45deg,
var(--#{$prefix}tertiary-bg) 25%,
transparent 25%,
transparent 50%,
var(--#{$prefix}tertiary-bg) 50%,
var(--#{$prefix}tertiary-bg) 75%,
transparent 75%,
transparent
);
background-size: 30px 30px;
animation: s3-file-field-stripes 2s linear infinite;
}
&.has-error {
border-color: var(--#{$prefix}danger);
}
&-message {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 1rem;
text-align: center;
}
&-upload-icon {
font-size: 2.75rem;
color: var(--#{$prefix}gray-400);
}
&-preview,
&-render {
position: absolute;
inset: 0;
overflow: hidden;
text-align: center;
background-color: var(--#{$prefix}body-bg);
}
&-render {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
img {
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
}
}
&-icon {
color: var(--#{$prefix}secondary-color);
}
&-infos {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
padding: 1.25rem;
background: rgba(var(--#{$prefix}dark-rgb), 0.7);
opacity: 0;
transition: opacity 0.15s linear;
pointer-events: none;
}
&-infos-inner {
min-width: 0;
width: 100%;
max-width: 100%;
text-align: center;
color: var(--#{$prefix}white);
}
&-filename {
margin: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-weight: $font-weight-bold;
line-height: 1.4;
}
&-hint {
margin: 0.75rem 0 0;
padding-top: 0.75rem;
border-top: 2px solid rgba(var(--#{$prefix}white-rgb), 0.5);
font-size: $font-size-sm;
opacity: 0.7;
}
&-remove {
position: absolute;
top: 0.625rem;
right: 0.625rem;
z-index: 3;
padding: 0.25rem 0.5rem;
border: 2px solid var(--#{$prefix}white);
background: transparent;
color: var(--#{$prefix}white);
font-size: 0.6875rem;
font-weight: $font-weight-bold;
letter-spacing: 0.04em;
text-transform: uppercase;
line-height: 1;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s linear, background-color 0.15s linear;
&:hover {
background: rgba(var(--#{$prefix}white-rgb), 0.2);
}
}
&-loader {
position: absolute;
top: 0.75rem;
right: 0.75rem;
z-index: 3;
color: var(--#{$prefix}white);
}
&.has-preview:hover,
&.has-preview.is-dragover,
&.is-uploading.has-preview {
.s3-file-field-infos {
opacity: 1;
}
.s3-file-field-remove {
opacity: 1;
pointer-events: auto;
}
}
&.is-uploading .s3-file-field-remove {
display: none;
}
}
@keyframes s3-file-field-stripes {
from {
background-position: 0 0;
}
to {
background-position: 60px 30px;
}
}

View File

@ -0,0 +1,31 @@
function NotFoundIcon() {
return (
<svg className="empty-state-svg" viewBox="0 0 80 80" fill="none" aria-hidden="true">
<rect className="empty-state-doc" x="8" y="8" width="46" height="60" rx="6" strokeWidth="2.25" />
<rect className="empty-state-clip" x="21" y="4" width="20" height="10" rx="3" strokeWidth="2.25" />
<path className="empty-state-line" d="M20 28h22M20 37h18M20 46h14" strokeWidth="2.25" strokeLinecap="round" />
<circle className="empty-state-glass" cx="56" cy="54" r="17" strokeWidth="2.5" />
<path className="empty-state-handle" d="M68.5 66.5 77 75" strokeWidth="3.25" strokeLinecap="round" />
</svg>
);
}
export default function EmptyState({ title = 'Not found', description = 'No data to show', size = 'md' }) {
return (
<div className={`empty-state${size === 'sm' ? ' is-sm' : ''}`}>
<NotFoundIcon />
<p className="empty-state-title">{title}</p>
{description ? <p className="empty-state-copy">{description}</p> : null}
</div>
);
}
export function EmptyTableRow({ colSpan = 20, title = 'Not found', description = 'No data to show' }) {
return (
<tr className="empty-state-row">
<td colSpan={colSpan}>
<EmptyState title={title} description={description} size="sm" />
</td>
</tr>
);
}

View File

@ -52,8 +52,20 @@ function defaultValue(field, item) {
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)) {
return [];
}
if ((field.type === 'multiselect' || field.type === 'bigmultiselect') && typeof raw === 'string') {
return raw.split(/[#~,]/).filter(Boolean);
if (field.type === 'multiselect' || field.type === 'bigmultiselect') {
const toIds = (value) => {
if (value == null || value === '') return [];
if (Array.isArray(value)) return value.flatMap(toIds);
if (typeof value === 'object') {
if (value.id != null || value.value != null) return [String(value.id ?? value.value)];
return Object.values(value).flatMap(toIds);
}
return String(value)
.split(/[#~,]/)
.map((part) => part.trim())
.filter(Boolean);
};
return toIds(raw);
}
if (field.type === 'checkbox' && typeof raw === 'string') {
return raw.split(/[#~,]/).filter(Boolean);
@ -78,7 +90,8 @@ function defaultValue(field, item) {
if (field.type === 'year_start_month' || field.key === 'year_start_month') {
return String(raw);
}
return raw;
if (typeof raw === 'object') return raw;
return raw === undefined || raw === null ? '' : String(raw);
}
function isFieldVisible(field, values) {
@ -267,16 +280,22 @@ function FieldControl({ field, control, register, moduleName, onUploadingChange
<Controller
name={field.key}
control={control}
rules={{ required: field.required }}
rules={{
validate: (value) => {
if (!field.required) return true;
const selected = Array.isArray(value) ? value.filter(Boolean) : [];
return selected.length > 0 || `Select ${field.title}`;
},
}}
render={({ field: rhf }) => (
<Select
isMulti
options={options}
value={options.filter((option) => (rhf.value || []).includes(option.value))}
value={options.filter((option) => (Array.isArray(rhf.value) ? rhf.value : []).map(String).includes(String(option.value)))}
onChange={(selected) => {
const next = applyExclusive(
(selected || []).map((option) => option.value),
rhf.value || [],
Array.isArray(rhf.value) ? rhf.value : [],
field.exclusiveValue,
);
rhf.onChange(next);
@ -414,6 +433,7 @@ export default function EntityForm({ module: config }) {
const [uploadingCount, setUploadingCount] = useState(0);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [formValues, setFormValues] = useState(null);
const { register, handleSubmit, reset, control, watch } = useForm();
const watched = watch();
const uploading = uploadingCount > 0;
@ -427,13 +447,14 @@ export default function EntityForm({ module: config }) {
setLoading(true);
setError('');
setMessage('');
setFormValues(null);
try {
let item = {};
const duplicateId = isAddNew ? searchParams.get('duplicate') : null;
const queryType = searchParams.get('type');
if (mode === 'edit') {
const params = singleton ? { ...(config.getOneParams || {}) } : { id, ...(config.getOneParams || {}) };
const response = await signedGet(config.service, `${config.module}/get_one`, params);
const response = await signedGet(config.getOneService || config.service, `${config.module}/get_one`, params);
if (!isSuccess(response)) {
setError(response?.message || 'Unable to load record');
return;
@ -441,7 +462,7 @@ export default function EntityForm({ module: config }) {
item = response.data?.item || {};
setRecordId(item.id || id || '');
} else if (duplicateId) {
const response = await signedGet(config.service, `${config.module}/get_one`, { id: duplicateId });
const response = await signedGet(config.getOneService || config.service, `${config.module}/get_one`, { id: duplicateId });
if (!isSuccess(response)) {
setError(response?.message || 'Unable to load record to duplicate');
return;
@ -455,7 +476,11 @@ export default function EntityForm({ module: config }) {
if (isAddNew && !item.type && config.defaultType) item.type = config.defaultType;
if (config.parentField) {
const fromUrl = searchParams.get(config.parentField.urlKey || 'id') || '';
setParentId(mode === 'edit' ? item[config.parentField.field] || '' : fromUrl);
const parentIdValue = mode === 'edit' ? item[config.parentField.field] || fromUrl : fromUrl;
setParentId(parentIdValue);
if (parentIdValue && !item[config.parentField.field]) {
item = { ...item, [config.parentField.field]: parentIdValue };
}
}
if (config.beforeForm) {
item = (await config.beforeForm(item, mode, { searchParams, duplicate: Boolean(duplicateId) })) || item;
@ -466,7 +491,7 @@ export default function EntityForm({ module: config }) {
resolved.forEach((field) => {
values[field.key] = defaultValue(field, item);
});
reset(values);
setFormValues(values);
} catch (err) {
setError(err.message || 'Unable to load form');
} finally {
@ -474,7 +499,16 @@ export default function EntityForm({ module: config }) {
}
};
boot();
}, [config, id, mode, reset, singleton, searchParams]);
}, [config, id, mode, singleton, searchParams, isAddNew]);
useEffect(() => {
if (loading || !formValues) return;
reset(formValues);
}, [loading, formValues, reset]);
const onInvalid = () => {
setError('Please fill all required fields.');
};
const onSubmit = async (values) => {
setSaving(true);
@ -530,7 +564,7 @@ export default function EntityForm({ module: config }) {
Back
</Link>
)}
<Button variant="success" onClick={handleSubmit(onSubmit)} disabled={saving || loading || uploading}>
<Button type="submit" form="entity-form" variant="success" disabled={saving || loading || uploading}>
{saving ? 'Saving...' : 'Submit'}
</Button>
</div>
@ -542,14 +576,14 @@ export default function EntityForm({ module: config }) {
<Spinner animation="border" />
</div>
) : (
<Form onSubmit={handleSubmit(onSubmit)}>
<Form id="entity-form" onSubmit={handleSubmit(onSubmit, onInvalid)}>
<Row>
{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} />
) : (
<Col md={field.fullwidth ? 12 : 6} className="mb-3" key={field.key}>
<Col md={field.fullwidth ? 12 : 6} className="mb-3 min-w-0" key={field.key}>
{field.hide_label ? null : (
<Form.Label>
{field.title} {field.required ? <span className="text-danger">*</span> : null}

View File

@ -8,10 +8,28 @@ import {
useReactTable,
} from '@tanstack/react-table';
import { Button, Card, Container, Form, Spinner, Table } from 'react-bootstrap';
import { TbBook, TbChartBar, TbCopy, TbDownload, TbEye, TbHelpCircle, TbPencil, TbPhoto, TbRefresh, TbTrash, TbUsers } from 'react-icons/tb';
import {
TbBook,
TbChartBar,
TbCopy,
TbDownload,
TbEye,
TbFileText,
TbHelpCircle,
TbListDetails,
TbPencil,
TbPhoto,
TbPlaylist,
TbRefresh,
TbSchool,
TbTrash,
TbTrophy,
TbUsers,
} from 'react-icons/tb';
import { signedGet, signedPost, isSuccess } from '@/api/client';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import { ListTableFooter, ListTableToolbar } from '@/components/ListTableControls';
import { EmptyTableRow } from '@/components/EmptyState';
import { downloadCsv, downloadRemoteFile, formatCsvDate } from '@/helpers/csv';
import { formatRoleNames, getRoleMap, isRoleListField, loadRoleMap } from '@/helpers/roles';
@ -22,7 +40,7 @@ function formatCell(value, field, roleMap) {
}
const type = field?.type;
if (type === 'image' || type === 'logo') {
return <img src={value} alt="" className="img-thumbnail" style={{ maxWidth: 48 }} />;
return <img src={value} alt="" className="img-thumbnail" style={{ maxWidth: 100 }} />;
}
if ((type === 'date' || type === 'datetime') && value) {
const date = new Date(value);
@ -80,6 +98,12 @@ const ACTION_ICONS = {
duplicate: TbCopy,
report: TbChartBar,
questions: TbHelpCircle,
responses: TbPlaylist,
skus: TbListDetails,
competitions: TbTrophy,
lessons: TbSchool,
articles: TbFileText,
personalizations: TbUsers,
};
export default function EntityList({ module: config }) {
@ -161,7 +185,7 @@ export default function EntityList({ module: config }) {
}
const rawItems = response.data?.items || [];
const list = Array.isArray(rawItems) ? rawItems : Object.values(rawItems);
const mapped = config.mapItems ? config.mapItems(list, response.data) : list;
const mapped = config.mapItems ? config.mapItems(list, response.data, { parentValue, parentCfg }) : list;
setItems(mapped);
setCount(response.data?.count ?? mapped.length);
} catch (err) {
@ -182,13 +206,11 @@ export default function EntityList({ module: config }) {
useEffect(() => {
load();
}, [config.module, pageIndex, pageSize, parentValue, appliedKeyword]);
}, [config.module, parentValue, appliedKeyword, isAjax ? pageIndex : null, isAjax ? pageSize : null]);
useEffect(() => {
if (location.state?.message) {
setMessage(location.state.message);
}
}, [location.state]);
setMessage(location.state?.message || '');
}, [config.module, location.pathname]);
useEffect(() => {
if (!config.passwordReset && !config.loginAudit) return undefined;
@ -473,6 +495,7 @@ export default function EntityList({ module: config }) {
...(isAjax ? {} : { getPaginationRowModel: getPaginationRowModel() }),
manualPagination: isAjax,
manualFiltering: isAjax,
autoResetPageIndex: false,
pageCount: isAjax ? Math.max(1, Math.ceil(count / pageSize)) : undefined,
globalFilterFn: (row, columnId, filterValue) => rowMatchesSearch(row, columnId, filterValue, roleMap),
state: { pagination: { pageIndex, pageSize }, globalFilter: isAjax ? '' : globalFilter },
@ -488,11 +511,15 @@ export default function EntityList({ module: config }) {
const withParentQuery = (to) => {
if (!to || !parentCfg || !parentValue) return to;
if (!String(to).includes('/addnew')) return to;
const join = String(to).includes('?') ? '&' : '?';
return `${to}${join}${parentUrlKey}=${encodeURIComponent(parentValue)}`;
const url = new URL(to, 'http://local.invalid');
url.searchParams.set(parentUrlKey, parentValue);
searchParams.forEach((value, key) => {
if (!url.searchParams.has(key)) url.searchParams.set(key, value);
});
return `${url.pathname}${url.search}`;
};
const headerButtons = [...(config.listButtons || [{ title: 'Add New', to: `${config.baseUrl}/addnew` }])];
const headerButtons = [...(config.listButtons || (config.skipAdd ? [] : [{ title: 'Add New', to: `${config.baseUrl}/addnew` }]))];
if (config.saveSelected && !headerButtons.some((button) => button.action === 'saveSelected')) {
headerButtons.push({ title: 'Save Selected', action: 'saveSelected' });
}
@ -500,13 +527,12 @@ export default function EntityList({ module: config }) {
return (
<Container fluid>
<PageBreadcrumb title={`${config.title} Management`} subtitle={config.subtitle || 'Organisation'} />
<div className="d-flex justify-content-end align-items-center mb-3 flex-wrap gap-2">
<div className="d-flex gap-2 flex-wrap align-items-center">
<div className="d-flex justify-content-end align-items-center mb-3 flex-nowrap gap-2 entity-list-actions">
<div className="d-flex gap-2 align-items-center flex-nowrap">
{config.loginAudit ? (
<>
<Form.Select
size="sm"
style={{ width: 'auto', minWidth: 150 }}
className="entity-list-actions-month"
value={auditMonth}
onChange={(e) => setAuditMonth(e.target.value)}
>
@ -516,7 +542,7 @@ export default function EntityList({ module: config }) {
</option>
))}
</Form.Select>
<Button size="sm" variant="primary" disabled={busyAction === 'audit'} onClick={handleLoginAudit}>
<Button variant="primary" disabled={busyAction === 'audit'} onClick={handleLoginAudit}>
{busyAction === 'audit' ? 'Downloading...' : 'Login Audit Report'}
</Button>
</>
@ -621,11 +647,7 @@ export default function EntityList({ module: config }) {
</thead>
<tbody>
{table.getRowModel().rows.length === 0 ? (
<tr>
<td colSpan={columns.length} className="text-center text-muted">
No records
</td>
</tr>
<EmptyTableRow colSpan={columns.length} />
) : (
table.getRowModel().rows.map((row) => (
<tr key={row.id}>

View File

@ -1,10 +1,7 @@
import { Link } from "react-router";
import { BreadcrumbItem } from 'react-bootstrap';
import { TbChevronRight } from 'react-icons/tb';
import PageMetaData from "@/components/PageMetaData";
const PageBreadcrumb = ({
title,
subtitle
title
}) => {
return <>
<PageMetaData title={title} />
@ -12,22 +9,8 @@ const PageBreadcrumb = ({
<div className="flex-grow-1">
<h4 className="fs-xl fw-bold m-0">{title}</h4>
</div>
<div className="text-end">
<div className="breadcrumb m-0 py-0 d-flex align-items-center gap-1">
<BreadcrumbItem linkAs={Link} href="">
Rapport
</BreadcrumbItem>{' '}
<TbChevronRight />
{subtitle && <>
<BreadcrumbItem linkAs={Link} href="">
{subtitle}
</BreadcrumbItem>{' '}
<TbChevronRight />
</>}
<BreadcrumbItem active>{title}</BreadcrumbItem>
</div>
</div>
</div>
</>;
};
export default PageBreadcrumb;
export default PageBreadcrumb;

View File

@ -1,10 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import Dropzone from 'react-dropzone';
import { Button } from 'react-bootstrap';
import { TbCloudUpload, TbX } from 'react-icons/tb';
import { Spinner } from 'react-bootstrap';
import { TbCloudUpload } from 'react-icons/tb';
import clsx from 'clsx';
import FileExtensionWithPreview from '@/components/FileExtensionWithPreview';
import { formatBytes } from '@/helpers/file';
import {
FILE_EXTENSIONS,
IMAGE_EXTENSIONS,
@ -75,7 +74,6 @@ export default function S3FileField({
const exts = extensionsFor(field);
const accept = acceptFromExtensions(exts);
const isImageField = field?.type === 'image' || field?.type === 'logo';
const clearPreview = () => {
if (previewUrlRef.current) {
@ -118,6 +116,7 @@ export default function S3FileField({
);
const onRemove = (event) => {
event.preventDefault();
event.stopPropagation();
if (uploading) return;
clearPreview();
@ -129,9 +128,10 @@ export default function S3FileField({
const previewSrc = localFile?.preview || (isImagePath(value, localFile?.type) ? value : '');
const displayName = localFile?.name || (value ? fileNameFromUrl(value) : '');
const hasFile = Boolean(localFile || value);
const extension = (displayName.split('.').pop() || '').toLowerCase();
return (
<div>
<div className="s3-file-field-wrap">
<input type="hidden" value={value || ''} required={required && !uploading} readOnly />
<Dropzone
onDrop={onDrop}
@ -143,74 +143,57 @@ export default function S3FileField({
>
{({ getRootProps, getInputProps, isDragActive }) => (
<div
className={clsx('dropzone dropzone-compact', isDragActive && 'border-primary')}
className={clsx(
's3-file-field',
hasFile && 'has-preview',
isDragActive && 'is-dragover',
uploading && 'is-uploading',
error && 'has-error',
)}
{...getRootProps()}
>
<input {...getInputProps()} />
<div className="dz-message needsclick d-flex align-items-center gap-2 m-0">
{hasFile ? (
previewSrc ? (
<img
src={previewSrc}
alt={displayName}
width={32}
height={32}
className="avatar-sm rounded bg-light flex-shrink-0"
/>
) : (
<FileExtensionWithPreview
className="flex-shrink-0"
width={32}
height={32}
extension={(displayName.split('.').pop() || '').toLowerCase()}
/>
)
) : (
<span className="avatar-sm flex-shrink-0">
<span className="avatar-title bg-info-subtle text-info rounded-circle">
<TbCloudUpload className="fs-16" />
{hasFile ? (
<>
<div className="s3-file-field-preview">
<div className="s3-file-field-render">
{previewSrc ? (
<img src={previewSrc} alt="" />
) : (
<FileExtensionWithPreview className="s3-file-field-icon" width={72} height={72} extension={extension} />
)}
</div>
<div className="s3-file-field-infos">
<div className="s3-file-field-infos-inner">
<p className="s3-file-field-filename" title={displayName}>
{displayName}
</p>
<p className="s3-file-field-hint">
{uploading ? 'Uploading...' : 'Drag and drop or click to replace'}
</p>
</div>
</div>
</div>
{uploading ? (
<span className="s3-file-field-loader">
<Spinner animation="border" size="sm" />
</span>
</span>
)}
<div className="flex-grow-1 min-w-0 text-start">
<div className="fw-semibold text-truncate" title={hasFile ? value || displayName : undefined}>
{hasFile
? displayName
: isImageField
? 'Drop or click to upload an image'
: 'Drop or click to upload a file'}
</div>
<div className="text-muted fs-12 text-truncate">
{uploading
? 'Uploading...'
: hasFile
? localFile?.size
? formatBytes(localFile.size)
: 'Uploaded'
: `${exts.join(', ')} · 500MB max`}
</div>
) : (
<button type="button" className="s3-file-field-remove" onClick={onRemove}>
Remove
</button>
)}
</>
) : (
<div className="s3-file-field-message">
<TbCloudUpload className="s3-file-field-upload-icon" />
<p className="mb-0">Drag and drop a file here or click</p>
</div>
{hasFile ? (
<Button
type="button"
variant="link"
size="sm"
className="text-danger p-0 flex-shrink-0"
onClick={onRemove}
disabled={uploading}
>
<TbX className="fs-18" />
</Button>
) : (
<button type="button" className="btn btn-sm btn-default flex-shrink-0">
Browse
</button>
)}
</div>
)}
</div>
)}
</Dropzone>
<p className="text-muted text-center mt-2 mb-0">500MB Max File size</p>
{error ? <div className="text-danger fs-12 mt-1">{error}</div> : null}
</div>
);

View File

@ -2,10 +2,11 @@ import { flexRender } from '@tanstack/react-table';
import clsx from 'clsx';
import { Table } from 'react-bootstrap';
import { TbArrowDown, TbArrowUp } from 'react-icons/tb';
import { EmptyTableRow } from '@/components/EmptyState';
const DataTable = ({
table,
className = '',
emptyMessage = 'Nothing found.',
emptyMessage = 'No data to show',
showHeaders = true
}) => {
const columns = table.getAllColumns();
@ -30,11 +31,7 @@ const DataTable = ({
<tbody>
{table.getRowModel().rows?.length ? table.getRowModel().rows.map(row => <tr key={row.id}>
{row.getVisibleCells().map(cell => <td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>)}
</tr>) : <tr>
<td colSpan={columns.length} className="text-center py-3 text-muted">
{emptyMessage}
</td>
</tr>}
</tr>) : <EmptyTableRow colSpan={columns.length} description={emptyMessage} />}
</tbody>
</Table>
</div>;

View File

@ -6,6 +6,13 @@ export function splitHash(value) {
return [];
}
export function joinHash(value) {
if (Array.isArray(value)) return value.filter(Boolean).join('#');
return value || '';
}
export const STATUS_VALUES = { active: 'Active', disabled: 'Disabled' };
export function splitTilde(value) {
if (Array.isArray(value)) return value.filter((part) => part !== null && part !== undefined && part !== '');
if (typeof value === 'string' && value.trim()) return value.split('~').filter(Boolean);

View File

@ -48,7 +48,6 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
'hierarchy_get_all',
'team_get_all',
'group_get_all',
'channel_get_all',
'domain_get_one',
])
) {
@ -64,7 +63,6 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
child('positions', 'Positions', '/positions', 'all_data_access'),
child('teams', 'Teams', '/teams', 'team_get_all'),
child('groups', 'Groups', '/groups', 'group_get_all'),
child('channels', 'Channels', '/channels', 'channel_get_all'),
child('domain', 'Domain', '/domain/edit', 'domain_get_one'),
child('apps', 'Apps', '/apps', 'app_get_all'),
].filter((item) => !item.permission || permissions.includes(item.permission)),
@ -79,7 +77,7 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
});
}
if (hasAny(permissions, ['contact_get_all', 'contactattribute_get_all'])) {
if (hasAny(permissions, ['contact_get_all', 'contactattribute_get_all', 'contact_list_get_all'])) {
items.push({
key: 'contacts',
label: 'Contact Management',
@ -88,6 +86,7 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
child('contact-list', 'Contacts', '/contacts', 'contact_get_all'),
child('contact-export', 'Contact Export', '/contactexports', '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)),
});
}
@ -181,6 +180,19 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
});
}
if (hasAny(permissions, ['courses_get_all', 'articles_bank_get_all', 'lms_quiz_get_all'])) {
items.push({
key: 'lms',
label: 'LMS',
icon: LuBookOpen,
children: [
child('courses', 'Courses', '/courses', 'courses_get_all'),
child('articlebanks', 'Article Banks', '/articlebanks', 'articles_bank_get_all'),
child('lmsquizs', 'Quiz', '/lmsquizs', 'lms_quiz_get_all'),
].filter((item) => !item.permission || permissions.includes(item.permission)),
});
}
if (hasAny(permissions, ['rcpa_product_get_all', 'rcpa_reports'])) {
items.push({
key: 'rcpa',
@ -196,6 +208,18 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
});
}
if (permissions.includes('contest_get_all')) {
items.push({
key: 'detailing',
label: 'Detailing',
icon: LuBookOpen,
children: [
child('detailing-contests', 'Contests', '/detailingcontests'),
child('contest-parameters', 'Parameters', '/contestparameters'),
],
});
}
if (hasAny(permissions, ['metric_get_all', 'injestion_mapping_get_all', 'metric_view_get_all'])) {
items.push({
key: 'metric',
@ -244,6 +268,7 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
'formexports_get_all',
'quiz_get_all',
'roles_leaderboard_get_all',
'formproduct_get_all',
])
) {
const formChildren = [
@ -255,6 +280,7 @@ export function getMenuItems({ permissions = [], roles = [], domain = '', email
child('formexports', 'Form Data Export', '/formexports', 'formexports_get_all'),
child('reminders', 'Reminders', '/reminders', 'reminder_get_all'),
child('leaderboards', 'Leaderboard', '/leaderboards', 'roles_leaderboard_get_all'),
child('formproducts', 'Products', '/formproducts', 'formproduct_get_all'),
].filter((item) => !item.permission || permissions.includes(item.permission));
if (domain === 'titans' && permissions.includes('all_data_access')) {

View File

@ -0,0 +1,42 @@
const STATUS = { active: 'Active', disabled: 'Disabled' };
const articlebanks = {
service: 'lms',
module: 'articles_bank',
title: 'Article Bank',
subtitle: 'LMS',
baseUrl: '/articlebank',
listPath: '/articlebanks',
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ key: 'title', title: 'Title', type: 'text' },
{ key: 'description', title: 'Description', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
formFields: () => [
{ key: 'title', title: 'Title', type: 'text', required: true },
{ key: 'description', title: 'Description', type: 'text', required: true },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
{ key: 'contents', title: 'Content', type: 'mapping', fullwidth: true },
{ key: 'icon', title: 'Icon', type: 'file', required: true },
],
beforeForm: (item) => ({
...item,
contents: Array.isArray(item.contents)
? item.contents.map((row) => ({
value: row.type || row.value || '',
display: row.content || row.display || '',
}))
: [],
}),
beforeSave: (data) => ({
...data,
contents: (Array.isArray(data.contents) ? data.contents : []).map((row, index) => ({
order: index + 1,
type: row.value || row.type || '',
content: row.display || row.content || '',
})),
}),
};
export default articlebanks;

51
src/modules/articles.js Normal file
View File

@ -0,0 +1,51 @@
import { signedGet, isSuccess } from '@/api/client';
const STATUS = { active: 'Active', disabled: 'Disabled' };
const articles = {
service: 'lms',
module: 'articles',
title: 'Article',
subtitle: 'LMS',
baseUrl: '/article',
listPath: '/articles',
missingParentMessage: 'Lesson not found',
parentParam: { urlKey: 'id', apiKey: 'lessons_id' },
parentField: { urlKey: 'id', field: 'lessons_id' },
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ key: 'type', title: 'Type', type: 'text' },
{ key: 'lessons_id', title: 'Lesson', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
listButtons: [
{ title: 'Add New', to: '/article/addnew' },
{ title: 'Back to Courses', to: '/courses' },
],
formFields: async () => {
const [banksRes, quizRes] = await Promise.all([
signedGet('lms', 'articles_bank/get_all'),
signedGet('lms', 'lmsquiz/get_all'),
]);
const entities = {};
(isSuccess(banksRes) ? banksRes.data?.items || [] : []).forEach((item) => {
if (item?.id) entities[item.id] = item.title || item.id;
});
(isSuccess(quizRes) ? quizRes.data?.items || [] : []).forEach((item) => {
if (item?.id) entities[item.id] = item.title || item.id;
});
return [
{ key: 'type', title: 'Type', type: 'select', required: true, values: { article: 'Article', quiz: 'Quiz' } },
{ key: 'entity_id', title: 'Select Form', type: 'select', required: true, values: entities },
{ key: 'sort_order', title: 'Sequence Of Article', type: 'text', required: true },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
{ key: 'icon', title: 'Icon', type: 'file', required: true },
];
},
beforeForm: (item, _mode, ctx) => ({
...item,
course_id: item.course_id || ctx?.searchParams?.get('course_id') || '',
}),
};
export default articles;

35
src/modules/brands.js Normal file
View File

@ -0,0 +1,35 @@
const STATUS = { active: 'Active', disabled: 'Disabled' };
const brands = {
service: 'catalog',
module: 'brand',
title: 'Brand',
subtitle: 'Catalog',
baseUrl: '/brand',
listPath: '/brands',
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ key: 'name', title: 'Brand Name', type: 'text' },
{ key: 'type', title: 'Brand Type', type: 'text' },
{ key: 'division', title: 'Division', type: 'text' },
{ key: 'company', title: 'Company', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
formFields: () => [
{ key: 'name', title: 'Brand Name', type: 'text', required: true },
{ key: 'type', title: 'Brand Type', type: 'select', values: { MDI: 'MDI', DPI: 'DPI', Oral: 'Oral' } },
{ key: 'division', title: 'Division', type: 'text' },
{ key: 'company', title: 'Company', type: 'text' },
{ key: 'focus_brand', title: 'Focus Brand', type: 'text' },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
],
beforeSave: (data) => ({
...data,
type: data.type || '',
division: data.division || '',
company: data.company || '',
focus_brand: data.focus_brand || '',
}),
};
export default brands;

View File

@ -0,0 +1,23 @@
const STATUS = { active: 'Active', disabled: 'Disabled' };
const contactlists = {
service: 'contact',
module: 'contact_list',
title: 'Contact List',
subtitle: 'Contact Management',
baseUrl: '/contactlist',
listPath: '/contactlists',
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ key: 'name', title: 'Name', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
formFields: () => [
{ key: 'name', title: 'Name', type: 'text', required: true },
{ key: 'type', title: 'Type', type: 'select', values: { normal: 'Normal', dynamic: 'Dynamic' }, required: true },
{ key: 'conditions', title: 'Conditions', type: 'textarea', fullwidth: true },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
],
};
export default contactlists;

View File

@ -0,0 +1,24 @@
const STATUS = { active: 'Active', disabled: 'Disabled' };
const contestparameters = {
service: 'detailing',
module: 'parameter',
title: 'Contest Parameters',
subtitle: 'Detailing',
baseUrl: '/contestparameter',
listPath: '/contestparameters',
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ key: 'title', title: 'Title', type: 'text' },
{ key: 'scale', title: 'Scale', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
formFields: () => [
{ key: 'title', title: 'Title', type: 'text', required: true },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
{ key: 'description', title: 'Contest Description', type: 'textarea', required: true, fullwidth: true },
{ key: 'scale', title: 'Scale', type: 'number', required: true },
],
};
export default contestparameters;

45
src/modules/courses.js Normal file
View File

@ -0,0 +1,45 @@
import { toDateInput } from '@/helpers/metric';
const STATUS = { active: 'Active', disabled: 'Disabled' };
const courses = {
service: 'lms',
module: 'courses',
title: 'Courses',
subtitle: 'LMS',
baseUrl: '/course',
listPath: '/courses',
listFields: [
{
key: 'actions',
title: 'Actions',
buttons: [
'edit',
'delete',
{ name: 'lessons', to: (row) => `/lessons?id=${row.id}`, title: 'Lessons' },
],
},
{ key: 'title', title: 'Title', type: 'text' },
{ key: 'description', title: 'Description', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
formFields: () => [
{ key: 'title', title: 'Title', type: 'text', required: true },
{ key: 'description', title: 'Description', type: 'text', required: true },
{ key: 'is_scheduled', title: 'Is Scheduled', type: 'radio', required: true, values: { yes: 'Yes', no: 'No' } },
{ key: 'is_sequential', title: 'Is Sequential', type: 'radio', required: true, values: { yes: 'Yes', no: 'No' } },
{ key: 'start_date', title: 'Schedule start date', type: 'date', required: true },
{ key: 'end_date', title: 'Schedule end date', type: 'date', required: true },
{ key: 'deadline', title: 'Deadline', type: 'date', required: true },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
{ key: 'icon', title: 'Icon', type: 'file', required: true },
],
beforeForm: (item) => ({
...item,
start_date: toDateInput(item.start_date),
end_date: toDateInput(item.end_date),
deadline: toDateInput(item.deadline),
}),
};
export default courses;

View File

@ -0,0 +1,36 @@
const detailingcontestresponses = {
service: 'detailing',
module: 'contest_response',
title: 'Detailing Contest Responses',
subtitle: 'Detailing',
baseUrl: '/detailingcontestresponse',
listPath: '/detailingcontestresponses',
missingParentMessage: 'Contest not found',
parentParam: { urlKey: 'id', apiKey: 'con_id' },
parentField: { urlKey: 'id', field: 'con_id' },
skipEdit: true,
skipAdd: true,
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['delete'] },
{ key: 'avatar', title: 'User Avatar', type: 'image' },
{ key: 'user_name', title: 'User Name', type: 'text' },
{ key: 'title', title: 'Media Name', type: 'text' },
{ key: 'media_type', title: 'Media Type', type: 'text' },
{ key: 'createdAt', title: 'Submitted On', type: 'date' },
{ key: 'marks', title: 'Avg. Marks', type: 'text' },
{ key: 'total', title: 'Avg. Total', type: 'text' },
{ key: 'result', title: 'Result', type: 'text' },
{ key: 'no_of_evaluations', title: 'No of Evaluations', type: 'text' },
{ key: 'likes_count', title: 'Likes', type: 'text' },
{ key: 'comments_count', title: 'Comments', type: 'text' },
],
listButtons: [{ title: 'Back to Detailing Contests', to: '/detailingcontests' }],
mapItems: (items) =>
items.map((item) => ({
...item,
user_name: item.user_name || item.user_info?.name || '',
avatar: item.avatar || item.user_info?.avatar || '',
})),
};
export default detailingcontestresponses;

View File

@ -0,0 +1,126 @@
import { dropdownMap } from '@/api/dropdowns';
import { signedGet, isSuccess } from '@/api/client';
import { joinHash, splitHash } from '@/helpers/metric';
const STATUS = { active: 'Active', disabled: 'Disabled' };
const detailingcontests = {
service: 'detailing',
module: 'contest',
title: 'Detailing Contests',
subtitle: 'Detailing',
baseUrl: '/detailingcontest',
listPath: '/detailingcontests',
listFields: [
{
key: 'actions',
title: 'Actions',
buttons: [
'edit',
'delete',
{ name: 'responses', to: (row) => `/detailingcontestresponses?id=${row.id}`, title: 'Responses' },
],
},
{ key: 'name', title: 'Title', type: 'text' },
{ key: 'marks', title: 'Marks', type: 'text' },
{ key: 'no_of_participants', title: 'No of participants', type: 'text' },
{ key: 'no_of_uploads', title: 'No of uploads', type: 'text' },
{ key: 'no_of_evaluations', title: 'No of evaluations', type: 'text' },
{ key: 'pass_count', title: 'Pass Count', type: 'text' },
{ key: 'fail_count', title: 'Fail Count', type: 'text' },
{ key: 'published_on', title: 'Published On', type: 'date' },
{ key: 'is_published', title: 'Published', type: 'select' },
{ key: 'is_result_published', title: 'Result Published', type: 'select' },
{ key: 'status', title: 'Status', type: 'select' },
],
formFields: async () => {
const [groups, users, roles, paramsRes] = await Promise.all([
dropdownMap('ums', 'group', (item) => item.name),
dropdownMap('ums', 'users', (item) => `${item.firstname || ''} ${item.lastname || ''} (${item.employee_code || ''})`),
dropdownMap('ums', 'roles', (item) => item.name),
signedGet('detailing', 'parameter/get_all'),
]);
const params = {};
(isSuccess(paramsRes) ? paramsRes.data?.items || [] : []).forEach((item) => {
if (item?.id) params[item.id] = `${item.title || item.name} (Scale: ${item.scale ?? ''})`;
});
return [
{ key: 'name', title: 'Contest Name', type: 'text', required: true },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
{ key: 'description', title: 'Contest Description', type: 'textarea', required: true, fullwidth: true },
{ key: 'groups', title: 'Groups', type: 'multiselect', required: true, values: groups },
{ key: 'evaluators', title: 'Evaluators', type: 'multiselect', required: true, values: users },
{ key: 'observers', title: 'Observers', type: 'multiselect', required: true, values: users },
{ key: 'evaluation_parameters', title: 'Evaluation Parameters', type: 'multiselect', required: true, values: params },
{ key: 'dashboard_image', title: 'Dashboard Image', type: 'image', required: true },
{ key: 'cover_image', title: 'Cover Image', type: 'image', required: true },
{ key: 'media_type', title: 'Media Type', type: 'select', required: true, values: { audio: 'Audio', video: 'Video', pdf: 'PDF', image: 'Image' } },
{ key: 'media', title: 'Media', type: 'file', required: true },
{ key: 'response_types', title: 'Response Types', type: 'checkbox', required: true, values: { audio: 'Audio', video: 'Video', pdf: 'PDF', image: 'Image' } },
{ key: 'response_sources', title: 'Response Sources', type: 'multiselect', required: true, values: { CAMERA: 'CAMERA', GALLERY: 'GALLERY' } },
{ key: 'limits_audio', title: 'Audio Duration Limit (in sec)', type: 'number', required: true },
{ key: 'limits_video', title: 'Video Duration Limit (in sec)', type: 'number', required: true },
{ key: 'limits_size', title: 'Max file Size (in MB)', type: 'number', required: true },
{ key: 'response_self_delete', title: 'Can delete self Response before Evaluation', type: 'yesno', required: true },
{ key: 'allow_self_evaluation', title: 'Is Self Evaluation Enabled', type: 'yesno', required: true },
{ key: 'evaluation_workflow', title: 'Evaluator Type', type: 'select', required: true, values: { ANY: 'ANY', ALL: 'ALL' } },
{ key: 'result_publish_count', title: 'Result Publish Count', type: 'number', required: true },
{ key: 'marks', title: 'Marks', type: 'number', required: true },
{ key: 'is_reattempt_allowed', title: 'Is Reattempt allowed', type: 'yesno', required: true },
{ key: 'no_of_reattempts', title: 'No of Reattempts', type: 'number', required: true },
{ key: 'validate_evaluations', title: 'Validate Evaluations', type: 'yesno', required: true },
{
key: 'validation_type',
title: 'Validation Type',
type: 'select',
required: true,
values: { none: 'None', warning: 'Warning', warning_for_top: 'Warning for top', stop: 'Stop', stop_for_top: 'Stop for top' },
},
{
key: 'additional_evaluators',
title: 'Additional Evaluators',
type: 'checkbox',
required: true,
values: { level_1_manager: '1st line manager', level_2_manager: '2nd line manager', random_manager: 'Random Manager' },
},
{ key: 'response_roles', title: 'Accept Response from Roles', type: 'multiselect', required: true, values: roles },
{ key: 'show_pass_fail_icon', title: 'Show Pass/Fail icons on Responses', type: 'yesno', required: true, fullwidth: true },
{ key: 'pass_icon', title: 'Pass Icon', type: 'image', required: true },
{ key: 'fail_icon', title: 'Fail Icon', type: 'image', required: true },
];
},
beforeForm: (item) => ({
...item,
groups: splitHash(item.groups),
evaluators: splitHash(item.evaluators),
observers: splitHash(item.observers),
response_roles: splitHash(item.response_roles),
evaluation_parameters: splitHash(item.evaluation_parameters),
response_types: splitHash(item.response_types),
response_sources: splitHash(item.response_sources),
additional_evaluators: splitHash(item.additional_evaluators),
limits_video: item.limits_video ?? item.limits?.video ?? 0,
limits_audio: item.limits_audio ?? item.limits?.audio ?? 0,
limits_size: item.limits_size ?? item.limits?.size ?? 0,
}),
beforeSave: (data) => {
const payload = { ...data };
payload.groups = joinHash(payload.groups);
payload.evaluators = joinHash(payload.evaluators);
payload.observers = joinHash(payload.observers);
payload.response_roles = joinHash(payload.response_roles);
payload.evaluation_parameters = joinHash(payload.evaluation_parameters);
payload.response_types = Array.isArray(payload.response_types) ? payload.response_types : splitHash(payload.response_types);
payload.response_sources = Array.isArray(payload.response_sources) ? payload.response_sources : splitHash(payload.response_sources);
payload.additional_evaluators = Array.isArray(payload.additional_evaluators)
? payload.additional_evaluators
: splitHash(payload.additional_evaluators);
payload.limits = { video: payload.limits_video, audio: payload.limits_audio, size: payload.limits_size };
delete payload.limits_video;
delete payload.limits_audio;
delete payload.limits_size;
return payload;
},
};
export default detailingcontests;

View File

@ -48,6 +48,7 @@ const formentitys = {
'edit',
'delete',
{ name: 'questions', to: (row) => `/formquestions?id=${row.id}`, title: 'Questions' },
{ name: 'personalizations', to: (row) => `/formpersonalizations?id=${row.id}`, title: 'Personalizations' },
],
},
{ key: 'title', title: 'Title', type: 'text' },

View File

@ -0,0 +1,49 @@
import { signedGet, isSuccess } from '@/api/client';
import { splitHash } from '@/helpers/metric';
const STATUS = { active: 'Active', disabled: 'Disabled' };
const formpersonalizations = {
service: 'form',
module: 'personalization',
title: 'Form Personalization',
subtitle: 'Data Gathering',
baseUrl: '/formpersonalization',
listPath: '/formpersonalizations',
missingParentMessage: 'Form not found',
parentParam: { urlKey: 'id', apiKey: 'entity_id' },
parentField: { urlKey: 'id', field: 'entity_id' },
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ key: 'icon', title: 'Icon', type: 'image' },
{ key: 'title', title: 'Question', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
listButtons: [
{ title: 'Add New', to: '/formpersonalization/addnew' },
{ title: 'Back to Forms', to: '/formentitys' },
],
formFields: async ({ item } = {}) => {
const entityId = item?.entity_id || '';
const questions = {};
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;
});
}
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: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
{ key: 'icon', title: 'Icon', type: 'image', required: true },
];
},
beforeForm: (item, _mode, ctx) => ({
...item,
entity_id: item.entity_id || ctx?.searchParams?.get('id') || '',
questions: splitHash(item.questions),
}),
};
export default formpersonalizations;

View File

@ -0,0 +1,41 @@
const STATUS = { active: 'Active', disabled: 'Disabled' };
const formproducts = {
service: 'form',
module: 'formproduct',
title: 'Form Products',
subtitle: 'Data Gathering',
baseUrl: '/formproduct',
listPath: '/formproducts',
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ key: 'name', title: 'Name', type: 'text' },
{ key: 'code', title: 'Code', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
formFields: () => [
{ key: 'name', title: 'Name', type: 'text', required: true },
{ key: 'code', title: 'Code', type: 'text', required: true },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
{ key: 'skus', title: 'Skus', type: 'mapping', fullwidth: true },
],
beforeForm: (item) => ({
...item,
skus: Array.isArray(item.skus)
? item.skus.map((sku) => ({
value: sku.name || sku.value || '',
display: sku.code || sku.display || '',
}))
: [],
}),
beforeSave: (data) => ({
...data,
skus: (Array.isArray(data.skus) ? data.skus : []).map((row) => ({
name: row.value || row.name || '',
code: row.display || row.code || '',
competitors: Array.isArray(row.competitors) ? row.competitors : String(row.competitors || '').split(',').map((part) => part.trim()).filter(Boolean),
})),
}),
};
export default formproducts;

View File

@ -24,7 +24,7 @@ const leaderboards = {
baseUrl: '/leaderboard',
listPath: '/leaderboards',
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ 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: 'sort_order', title: 'Sort Order', type: 'number' },
{ key: 'visible_roles_label', title: 'Visible Roles', type: 'text' },

55
src/modules/lessons.js Normal file
View File

@ -0,0 +1,55 @@
import { signedGet, isSuccess } from '@/api/client';
const STATUS = { active: 'Active', disabled: 'Disabled' };
const lessons = {
service: 'lms',
module: 'lessons',
title: 'Lesson',
subtitle: 'LMS',
baseUrl: '/lesson',
listPath: '/lessons',
missingParentMessage: 'Course not found',
parentParam: { urlKey: 'id', apiKey: 'course_id' },
parentField: { urlKey: 'id', field: 'course_id' },
listFields: [
{
key: 'actions',
title: 'Actions',
buttons: [
'edit',
'delete',
{ name: 'articles', to: (row) => `/articles?id=${row.id}&course_id=${row.course_id || ''}`, title: 'Articles' },
],
},
{ key: 'title', title: 'Title', type: 'text' },
{ key: 'description', title: 'Description', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
listButtons: [
{ title: 'Add New', to: '/lesson/addnew' },
{ title: 'Back to Courses', to: '/courses' },
],
mapItems: (items, _data, ctx) =>
items.map((item) => ({
...item,
course_id: item.course_id || ctx?.parentValue || '',
})),
formFields: async () => {
const response = await signedGet('lms', 'courses/get_all');
const courses = {};
(isSuccess(response) ? response.data?.items || [] : []).forEach((item) => {
if (item?.id) courses[item.id] = item.title || item.id;
});
return [
{ key: 'course_id', title: 'Select Course', type: 'select', required: true, values: courses },
{ key: 'title', title: 'Title', type: 'text', required: true },
{ key: 'description', title: 'Description', type: 'text', required: true },
{ key: 'sort_order', title: 'Sequence Of Lesson', type: 'number', required: true },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
{ key: 'icon', title: 'Icon', type: 'file', required: true },
];
},
};
export default lessons;

29
src/modules/lmsquizs.js Normal file
View File

@ -0,0 +1,29 @@
const STATUS = { active: 'Active', disabled: 'Disabled' };
const lmsquizs = {
service: 'lms',
module: 'lmsquiz',
title: 'LMS Quiz',
subtitle: 'LMS',
baseUrl: '/lmsquiz',
listPath: '/lmsquizs',
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ key: 'title', title: 'Name', type: 'text' },
{ key: 'show_score', title: 'Show Score', type: 'text' },
{ key: 'show_answers', title: 'Show Answers', type: 'text' },
{ key: 'number_attempts', title: 'No. of Attempts', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
formFields: () => [
{ key: 'title', title: 'Name', type: 'text', required: true, fullwidth: true },
{ key: 'show_score', title: 'Show Score', type: 'select', required: true, values: { Yes: 'Yes', No: 'No' } },
{ key: 'show_answers', title: 'Show Answers', type: 'select', required: true, values: { Yes: 'Yes', No: 'No' } },
{ key: 'limit_attempts', title: 'Limit Attempts', type: 'select', required: true, values: { Yes: 'Yes', No: 'No' } },
{ key: 'number_attempts', title: 'No. of attempts', type: 'number', required: true },
{ key: 'passing_percent', title: 'Passing Percentage', type: 'number', required: true },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
],
};
export default lmsquizs;

View File

@ -0,0 +1,35 @@
const STATUS = { active: 'Active', disabled: 'Disabled' };
const OPTION_TYPES = ['select', 'checkbox', 'radio'];
const productattributes = {
service: 'catalog',
module: 'productattribute',
title: 'Product Attributes',
subtitle: 'Catalog',
baseUrl: '/productattribute',
listPath: '/productattributes',
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ key: 'attribute', title: 'Attribute', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
formFields: () => [
{ key: 'attribute', title: 'Attribute', type: 'text', required: true },
{ key: 'code', title: 'Code (small caps without spaces and special chars except "_")', type: 'text', required: true },
{ key: 'required', title: 'Is Required', type: 'yesno', required: true },
{ key: 'type', title: 'Attribute Type', type: 'select', required: true, values: { text: 'Text', number: 'Number', date: 'Date', email: 'Email', url: 'Url', select: 'Dropdown', checkbox: 'Checkbox', radio: 'Radio' } },
{ key: 'sort_order', title: 'Sort Order', type: 'number', required: true },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
{ key: 'options', title: 'Options', type: 'mapping', fullwidth: true, showIf: { type: OPTION_TYPES } },
],
beforeForm: (item) => ({
...item,
options: Array.isArray(item.options) ? item.options : [],
}),
beforeSave: (data) => ({
...data,
options: OPTION_TYPES.includes(data.type) ? data.options || [] : [],
}),
};
export default productattributes;

59
src/modules/products.js Normal file
View File

@ -0,0 +1,59 @@
import { signedGet, isSuccess } from '@/api/client';
import { dropdownMap } from '@/api/dropdowns';
const STATUS = { active: 'Active', disabled: 'Disabled' };
function optionValues(item) {
const values = {};
(item.options || []).forEach((option) => {
if (option && option.value !== undefined && option.value !== null) {
values[option.value] = option.display ?? option.value;
}
});
return values;
}
const products = {
service: 'catalog',
module: 'product',
title: 'Product',
subtitle: 'Catalog',
baseUrl: '/product',
listPath: '/products',
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ key: 'name', title: 'Name', type: 'text' },
{ key: 'sku', title: 'SKU', type: 'text' },
{ key: 'division', title: 'Division', type: 'text' },
{ key: 'company', title: 'Company', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
formFields: async () => {
const [attrRes, brands] = await Promise.all([
signedGet('catalog', 'productattribute/get_all'),
dropdownMap('catalog', 'brand', (item) => item.name),
]);
const fields = [
{ key: 'name', title: 'Name', type: 'text', required: true },
{ key: 'sku', title: 'SKU', type: 'text', required: true },
{ key: 'division', title: 'Division', type: 'text', required: true },
{ key: 'company', title: 'Company', type: 'text' },
{ key: 'brand', title: 'Brand', type: 'select', required: true, values: brands },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
];
const attributes = isSuccess(attrRes) ? attrRes.data?.items || [] : [];
attributes.forEach((item) => {
const needsOptions = ['select', 'radio', 'checkbox'].includes(item.type);
fields.push({
key: item.code,
title: item.attribute,
type: item.type || 'text',
required: item.required === 'yes',
values: needsOptions ? optionValues(item) : {},
});
});
return fields;
},
};
export default products;

View File

@ -0,0 +1,45 @@
const STATUS = { active: 'Active', disabled: 'Disabled' };
const rcpacompetitors = {
service: 'rcpa',
module: 'competitor',
title: 'RCPA Competitor',
subtitle: 'RCPA',
baseUrl: '/rcpacompetitor',
listPath: '/rcpacompetitors',
missingParentMessage: 'SKU not found',
parentParam: { urlKey: 'id', apiKey: 'sku_id' },
parentField: { urlKey: 'id', field: 'sku_id' },
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ key: 'title', title: 'Title', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
listButtons: [
{ title: 'Add New', to: '/rcpacompetitor/addnew' },
{ title: 'Back to Products', to: '/rcpaproducts' },
],
formFields: () => [
{ 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 },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
{ key: 'description', title: 'Description', type: 'textarea', required: true, fullwidth: true },
{ key: 'mrp_value', title: 'MRP Value', type: 'number', required: true },
{ key: 'pts_value', title: 'PTS Value', type: 'number', required: true },
{ key: 'ptr_value', title: 'PTR Value', type: 'number', required: true },
{ key: 'nrv_value', title: 'NRV Value', type: 'number', required: true },
{ key: 'sort_order', title: 'Sort Order', type: 'number', required: true },
],
beforeForm: (item, _mode, ctx) => ({
...item,
product_id: item.product_id || ctx?.searchParams?.get('product_id') || '',
}),
beforeSave: (data) => {
const payload = { ...data };
if (!payload.product_id) delete payload.product_id;
return payload;
},
};
export default rcpacompetitors;

85
src/modules/rcpaforms.js Normal file
View File

@ -0,0 +1,85 @@
import { signedGet, isSuccess } from '@/api/client';
import { dropdownMap } from '@/api/dropdowns';
import { splitHash } from '@/helpers/metric';
const STATUS = { active: 'Active', disabled: 'Disabled' };
function asArray(value) {
if (Array.isArray(value)) return value.filter((part) => part !== null && part !== undefined && part !== '');
return splitHash(value);
}
function yesno(value, fallback = 'no') {
return value ? String(value).toLowerCase() : fallback;
}
const rcpaforms = {
service: 'rcpa',
module: 'form',
title: 'RCPA Form',
subtitle: 'RCPA',
baseUrl: '/rcpaform',
listPath: '/rcpaforms',
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ key: 'title', title: 'Title', type: 'text' },
{ key: 'workflow', title: 'Workflow', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
formFields: async () => {
const [usersRes, roles, contactRes] = await Promise.all([
signedGet('ums', 'users/dropdown'),
dropdownMap('ums', 'roles', (item) => item.name),
signedGet('contact', 'contactattribute/get_all'),
]);
const approvers = { l1manager: 'L1 Manager', l2manager: 'L2 Manager' };
const userItems = isSuccess(usersRes) ? usersRes.data?.items || [] : [];
userItems.forEach((user) => {
if (!user?.id) return;
approvers[user.id] = `${user.firstname || ''} ${user.lastname || ''} (${user.employee_code || ''})`.trim();
});
const contactAttributes = {};
(isSuccess(contactRes) ? contactRes.data?.items || [] : []).forEach((attr) => {
if (attr?.code) contactAttributes[attr.code] = attr.attribute || attr.code;
});
return [
{ key: 'title', title: 'Title', type: 'text', required: true },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
{ key: 'metric_submission', title: 'Metric Submission', type: 'yesno', required: true },
{ key: 'metric_authkey', title: 'Auth Key', type: 'text', required: true },
{ key: 'manager_de_roles', title: 'Manager Data Entry Roles', type: 'multiselect', values: roles },
{ key: 'contact_attributes', title: 'Include Doctor Attributes in Export', type: 'multiselect', values: contactAttributes },
{ key: 'self_notification', title: 'Self Email Notification', type: 'yesno', required: true, fullwidth: true },
{ key: 'manager1_email_notification', title: '1st Level Manager Email Notification', type: 'yesno', required: true },
{ key: 'manager1_push_notification', title: '1st Level Manager Mobile Notification', type: 'yesno', required: true },
{ key: 'manager2_email_notification', title: '2nd Level Manager Email Notification', type: 'yesno', required: true },
{ key: 'manager2_push_notification', title: '2nd Level Manager Mobile Notification', type: 'yesno', required: true },
{ key: 'roles_email_notification', title: 'Email Notification to Roles', type: 'multiselect', values: roles },
{ key: 'roles_push_notification', title: 'Push Notification to Roles', type: 'multiselect', values: roles },
{ key: 'allow_non_hierarchy_submission', title: 'Allow Non Hierarchy Submission', type: 'yesno', required: true },
{ key: 'workflow', title: 'Enable Workflow', type: 'yesno', required: true },
{ key: 'workflow_type', title: 'Workflow type', type: 'select', required: true, values: { single: 'Single Persoon', multiple: 'Multiple person', sequencial: 'Sequencial' } },
{ key: 'approver_can_edit', title: 'Approver can edit entry', type: 'yesno', required: true },
{ key: 'is_personalized', title: 'Is Personalization Needed', type: 'yesno', required: true },
{ key: 'allow_delete_by_user', title: 'Allow Submission Delete by User', type: 'yesno', required: true },
{ key: 'export_fields', title: 'Export Fields', type: 'textarea', fullwidth: true },
{ key: 'workflow_approvers', title: 'Metric', type: 'mapping', required: true, approvers, fullwidth: true },
];
},
beforeForm: (item) => ({
...item,
self_notification: yesno(item.self_notification),
manager1_email_notification: yesno(item.manager1_email_notification),
manager1_push_notification: yesno(item.manager1_push_notification),
manager2_email_notification: yesno(item.manager2_email_notification),
manager2_push_notification: yesno(item.manager2_push_notification),
export_fields: item.export_fields || '',
manager_de_roles: asArray(item.manager_de_roles),
contact_attributes: asArray(item.contact_attributes),
roles_email_notification: asArray(item.roles_email_notification),
roles_push_notification: asArray(item.roles_push_notification),
workflow_approvers: Array.isArray(item.workflow_approvers) && item.workflow_approvers.length ? item.workflow_approvers : ['l1manager'],
}),
};
export default rcpaforms;

View File

@ -0,0 +1,85 @@
import { signedGet, isSuccess } from '@/api/client';
import { dropdownMap } from '@/api/dropdowns';
import { splitHash } from '@/helpers/metric';
const STATUS = { active: 'Active', disabled: 'Disabled' };
function asArray(value) {
if (Array.isArray(value)) return value.filter((part) => part !== null && part !== undefined && part !== '');
return splitHash(value);
}
function yesno(value, fallback = 'no') {
return value ? String(value).toLowerCase() : fallback;
}
const rcpaproductattributes = {
service: 'rcpa',
module: 'attribute',
title: 'RCPA Product Attribute',
subtitle: 'RCPA',
baseUrl: '/rcpaproductattribute',
listPath: '/rcpaproductattributes',
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ key: 'title', title: 'Title', type: 'text' },
{ key: 'workflow', title: 'Workflow', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
formFields: async () => {
const [usersRes, roles, contactRes] = await Promise.all([
signedGet('ums', 'users/dropdown'),
dropdownMap('ums', 'roles', (item) => item.name),
signedGet('contact', 'contactattribute/get_all'),
]);
const approvers = { l1manager: 'L1 Manager', l2manager: 'L2 Manager' };
const userItems = isSuccess(usersRes) ? usersRes.data?.items || [] : [];
userItems.forEach((user) => {
if (!user?.id) return;
approvers[user.id] = `${user.firstname || ''} ${user.lastname || ''} (${user.employee_code || ''})`.trim();
});
const contactAttributes = {};
(isSuccess(contactRes) ? contactRes.data?.items || [] : []).forEach((attr) => {
if (attr?.code) contactAttributes[attr.code] = attr.attribute || attr.code;
});
return [
{ key: 'title', title: 'Title', type: 'text', required: true },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
{ key: 'metric_submission', title: 'Metric Submission', type: 'yesno', required: true },
{ key: 'metric_authkey', title: 'Auth Key', type: 'text', required: true },
{ key: 'manager_de_roles', title: 'Manager Data Entry Roles', type: 'multiselect', values: roles },
{ key: 'contact_attributes', title: 'Include Doctor Attributes in Export', type: 'multiselect', values: contactAttributes },
{ key: 'self_notification', title: 'Self Email Notification', type: 'yesno', required: true, fullwidth: true },
{ key: 'manager1_email_notification', title: '1st Level Manager Email Notification', type: 'yesno', required: true },
{ key: 'manager1_push_notification', title: '1st Level Manager Mobile Notification', type: 'yesno', required: true },
{ key: 'manager2_email_notification', title: '2nd Level Manager Email Notification', type: 'yesno', required: true },
{ key: 'manager2_push_notification', title: '2nd Level Manager Mobile Notification', type: 'yesno', required: true },
{ key: 'roles_email_notification', title: 'Email Notification to Roles', type: 'multiselect', values: roles },
{ key: 'roles_push_notification', title: 'Push Notification to Roles', type: 'multiselect', values: roles },
{ key: 'allow_non_hierarchy_submission', title: 'Allow Non Hierarchy Submission', type: 'yesno', required: true },
{ key: 'workflow', title: 'Enable Workflow', type: 'yesno', required: true },
{ key: 'workflow_type', title: 'Workflow type', type: 'select', required: true, values: { single: 'Single Persoon', multiple: 'Multiple person', sequencial: 'Sequencial' } },
{ key: 'approver_can_edit', title: 'Approver can edit entry', type: 'yesno', required: true },
{ key: 'is_personalized', title: 'Is Personalization Needed', type: 'yesno', required: true },
{ key: 'allow_delete_by_user', title: 'Allow Submission Delete by User', type: 'yesno', required: true },
{ key: 'export_fields', title: 'Export Fields', type: 'textarea', fullwidth: true },
{ key: 'workflow_approvers', title: 'Metric', type: 'mapping', required: true, approvers, fullwidth: true },
];
},
beforeForm: (item) => ({
...item,
self_notification: yesno(item.self_notification),
manager1_email_notification: yesno(item.manager1_email_notification),
manager1_push_notification: yesno(item.manager1_push_notification),
manager2_email_notification: yesno(item.manager2_email_notification),
manager2_push_notification: yesno(item.manager2_push_notification),
export_fields: item.export_fields || '',
manager_de_roles: asArray(item.manager_de_roles),
contact_attributes: asArray(item.contact_attributes),
roles_email_notification: asArray(item.roles_email_notification),
roles_push_notification: asArray(item.roles_push_notification),
workflow_approvers: Array.isArray(item.workflow_approvers) && item.workflow_approvers.length ? item.workflow_approvers : ['l1manager'],
}),
};
export default rcpaproductattributes;

View File

@ -0,0 +1,31 @@
const STATUS = { active: 'Active', disabled: 'Disabled' };
const rcpaproducts = {
service: 'rcpa',
module: 'product',
title: 'RCPA Product',
subtitle: 'RCPA',
baseUrl: '/rcpaproduct',
listPath: '/rcpaproducts',
listFields: [
{
key: 'actions',
title: 'Actions',
buttons: [
'edit',
'delete',
{ name: 'skus', to: (row) => `/rcpaskus?id=${row.id}`, title: 'SKUs' },
],
},
{ key: 'title', title: 'Title', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
formFields: () => [
{ key: 'title', title: 'Title', type: 'text', required: true },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
{ key: 'description', title: 'Description', type: 'textarea', required: true, fullwidth: true },
{ key: 'sort_order', title: 'Sort Order', type: 'number', required: true },
],
};
export default rcpaproducts;

47
src/modules/rcpaskus.js Normal file
View File

@ -0,0 +1,47 @@
const STATUS = { active: 'Active', disabled: 'Disabled' };
const rcpaskus = {
service: 'rcpa',
module: 'sku',
title: 'RCPA Product SKU',
subtitle: 'RCPA',
baseUrl: '/rcpasku',
listPath: '/rcpaskus',
missingParentMessage: 'Product not found',
parentParam: { urlKey: 'id', apiKey: 'product_id' },
parentField: { urlKey: 'id', field: 'product_id' },
listFields: [
{
key: 'actions',
title: 'Actions',
buttons: [
'edit',
'delete',
{ name: 'competitions', to: (row) => `/rcpacompetitors?id=${row.id}&product_id=${row.product_id || ''}`, title: 'Competitors' },
],
},
{ key: 'title', title: 'Title', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
listButtons: [
{ title: 'Add New', to: '/rcpasku/addnew' },
{ title: 'Back to Products', to: '/rcpaproducts' },
],
mapItems: (items, _data, ctx) =>
items.map((item) => ({
...item,
product_id: item.product_id || ctx?.parentValue || '',
})),
formFields: () => [
{ key: 'title', title: 'Title', type: 'text', required: true },
{ key: 'status', title: 'Status', type: 'select', required: true, values: STATUS },
{ key: 'description', title: 'Description', type: 'textarea', required: true, fullwidth: true },
{ key: 'mrp_value', title: 'MRP Value', type: 'number', required: true },
{ key: 'pts_value', title: 'PTS Value', type: 'number', required: true },
{ key: 'ptr_value', title: 'PTR Value', type: 'number', required: true },
{ key: 'nrv_value', title: 'NRV Value', type: 'number', required: true },
{ key: 'sort_order', title: 'Sort Order', type: 'number', required: true },
],
};
export default rcpaskus;

95
src/modules/sessions.js Normal file
View File

@ -0,0 +1,95 @@
import { dropdownMap } from '@/api/dropdowns';
const sessions = {
service: 'events',
module: 'session',
title: 'Session',
subtitle: 'Events',
baseUrl: '/session',
listPath: '/sessions',
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ key: 'title', title: 'Session Title', type: 'text' },
{ key: 'session_type', title: 'Type', type: 'select' },
{ key: 'track_id', title: 'Track', type: 'text' },
{ key: 'presenter', title: 'Presenter', type: 'text' },
{ key: 'start_time', title: 'Start Time', type: 'datetime' },
{ key: 'end_time', title: 'End Time', type: 'datetime' },
{ key: 'room', title: 'Room', type: 'text' },
{ key: 'status', title: 'Status', type: 'select' },
],
formFields: async () => {
const tracks = await dropdownMap('events', 'track', (item) => item.name);
return [
{ key: 'track_id', title: 'Track', type: 'select', required: true, values: tracks },
{ key: 'title', title: 'Session Title', type: 'text', required: true },
{ key: 'description', title: 'Description', type: 'textarea' },
{
key: 'session_type',
title: 'Session Type',
type: 'select',
required: true,
values: {
presentation: 'Presentation',
workshop: 'Workshop',
panel: 'Panel Discussion',
networking: 'Networking',
break: 'Break',
lunch: 'Lunch',
keynote: 'Keynote',
demo: 'Demo',
qna: 'Q&A Session',
other: 'Other',
},
},
{ key: 'status', title: 'Status', type: 'select', required: true, values: { scheduled: 'Scheduled', live: 'Live', completed: 'Completed', cancelled: 'Cancelled' } },
{ key: 'start_time', title: 'Start Time', type: 'datetime', required: true },
{ key: 'end_time', title: 'End Time', type: 'datetime', required: true },
{ key: 'duration', title: 'Duration (minutes)', type: 'number', default: 60 },
{ key: 'presenter', title: 'Presenter Name', type: 'text' },
{ key: 'presenter_title', title: 'Presenter Title', type: 'text' },
{ key: 'presenter_bio', title: 'Presenter Bio', type: 'textarea' },
{ key: 'presenter_avatar', title: 'Presenter Avatar', type: 'image' },
{ key: 'max_attendees', title: 'Max Attendees', type: 'number', default: 0 },
{ key: 'is_interactive', title: 'Interactive Session', type: 'yesno', required: true },
{ key: 'requires_registration', title: 'Requires Registration', type: 'yesno', required: true },
{ key: 'is_recorded', title: 'Record Session', type: 'yesno', required: true },
{ key: 'room', title: 'Room/Venue', type: 'text' },
{ key: 'virtual_link', title: 'Virtual Link', type: 'text' },
{ key: 'presentation_url', title: 'Presentation URL', type: 'text' },
{ key: 'tags', title: 'Tags', type: 'text', placeholder: 'Enter tags separated by commas' },
];
},
beforeForm: (item) => {
const presenter = item.presenter && typeof item.presenter === 'object' ? item.presenter : {};
return {
...item,
presenter: typeof item.presenter === 'string' ? item.presenter : presenter.name || '',
presenter_title: item.presenter_title || presenter.title || '',
presenter_bio: item.presenter_bio || presenter.bio || '',
presenter_avatar: item.presenter_avatar || presenter.avatar || '',
tags: Array.isArray(item.tags) ? item.tags.join(', ') : item.tags || '',
};
},
beforeSave: (data) => {
const payload = { ...data };
payload.tags = String(payload.tags || '')
.split(',')
.map((part) => part.trim())
.filter(Boolean);
payload.max_attendees = payload.max_attendees || 0;
payload.duration = payload.duration || 60;
payload.presenter = {
name: payload.presenter || '',
title: payload.presenter_title || '',
bio: payload.presenter_bio || '',
avatar: payload.presenter_avatar || '',
};
delete payload.presenter_title;
delete payload.presenter_bio;
delete payload.presenter_avatar;
return payload;
},
};
export default sessions;

View File

@ -1,7 +1,8 @@
import { dropdownMap } from '@/api/dropdowns';
const teams = {
service: 'ums',
service: 'detailing',
getOneService: 'ums',
module: 'team',
title: 'Teams',
baseUrl: '/team',

52
src/modules/tracks.js Normal file
View File

@ -0,0 +1,52 @@
import { dropdownMap } from '@/api/dropdowns';
const tracks = {
service: 'events',
module: 'track',
title: 'Track',
subtitle: 'Events',
baseUrl: '/track',
listPath: '/tracks',
listFields: [
{ key: 'actions', title: 'Actions', buttons: ['edit', 'delete'] },
{ key: 'name', title: 'Track Name', type: 'text' },
{ key: 'track_type', title: 'Type', type: 'select' },
{ key: 'event_id', title: 'Event', type: 'text' },
{ key: 'room', title: 'Room', type: 'text' },
{ key: 'max_attendees', title: 'Max Attendees', type: 'number' },
{ key: 'order', title: 'Order', type: 'number' },
{ key: 'status', title: 'Status', type: 'select' },
],
formFields: async () => {
const events = await dropdownMap('events', 'event', (item) => item.name);
return [
{ key: 'event_id', title: 'Event', type: 'select', required: true, values: events },
{ key: 'name', title: 'Track Name', type: 'text', required: true },
{ key: 'description', title: 'Description', type: 'textarea' },
{
key: 'track_type',
title: 'Track Type',
type: 'select',
required: true,
values: {
general: 'General',
technical: 'Technical',
business: 'Business',
networking: 'Networking',
workshop: 'Workshop',
panel: 'Panel Discussion',
keynote: 'Keynote',
other: 'Other',
},
},
{ key: 'status', title: 'Status', type: 'select', required: true, values: { active: 'Active', inactive: 'Inactive', completed: 'Completed' } },
{ key: 'max_attendees', title: 'Max Attendees', type: 'number', default: 0 },
{ key: 'is_parallel', title: 'Parallel Track', type: 'yesno', required: true },
{ key: 'order', title: 'Display Order', type: 'number', default: 0 },
{ key: 'room', title: 'Room/Venue', type: 'text' },
{ key: 'virtual_link', title: 'Virtual Link', type: 'text' },
];
},
};
export default tracks;

View File

@ -96,6 +96,33 @@ const users = {
}
return fields;
},
beforeForm: (item) => ({
...item,
roles: asIdList(item?.roles ?? item?.role),
channels: asIdList(item?.channels ?? item?.channel),
}),
beforeSave: (data) => {
const payload = { ...data };
payload.roles = asIdList(payload.roles);
payload.channels = asIdList(payload.channels);
if (!payload.roles.length) delete payload.roles;
if (!payload.channels.length) delete payload.channels;
return payload;
},
};
function asIdList(value) {
if (value == null || value === '') return [];
if (Array.isArray(value)) return value.flatMap(asIdList);
if (typeof value === 'object') {
if (value.id != null && value.id !== '') return [String(value.id)];
if (value.value != null && value.value !== '') return [String(value.value)];
return Object.values(value).flatMap(asIdList);
}
return String(value)
.split(/[#~,]/)
.map((part) => part.trim())
.filter(Boolean);
}
export default users;

View File

@ -43,6 +43,27 @@ import formactions from '@/modules/formactions';
import formexports from '@/modules/formexports';
import reminders from '@/modules/reminders';
import leaderboards from '@/modules/leaderboards';
import detailingcontests from '@/modules/detailingcontests';
import contestparameters from '@/modules/contestparameters';
import detailingcontestresponses from '@/modules/detailingcontestresponses';
import contactlists from '@/modules/contactlists';
import rcpaproducts from '@/modules/rcpaproducts';
import rcpaskus from '@/modules/rcpaskus';
import rcpacompetitors from '@/modules/rcpacompetitors';
import rcpaforms from '@/modules/rcpaforms';
import rcpaproductattributes from '@/modules/rcpaproductattributes';
import brands from '@/modules/brands';
import products from '@/modules/products';
import productattributes from '@/modules/productattributes';
import formproducts from '@/modules/formproducts';
import formpersonalizations from '@/modules/formpersonalizations';
import courses from '@/modules/courses';
import lessons from '@/modules/lessons';
import articles from '@/modules/articles';
import articlebanks from '@/modules/articlebanks';
import lmsquizs from '@/modules/lmsquizs';
import sessions from '@/modules/sessions';
import tracks from '@/modules/tracks';
const Login = lazy(() => import('@/views/auth/Login'));
const LoginOtp = lazy(() => import('@/views/auth/LoginOtp'));
@ -60,12 +81,15 @@ const HierarchyTree = lazy(() => import('@/views/organisation/HierarchyTree'));
const ImportWizard = lazy(() => import('@/views/organisation/ImportWizard'));
const TeamPlayers = lazy(() => import('@/views/organisation/TeamPlayers'));
const MagMediaViewers = lazy(() => import('@/views/content/MagMediaViewers'));
const LeaderboardDashboard = lazy(() => import('@/views/leaderboard/Dashboard'));
function moduleRoutes(config) {
const routes = [
{ path: config.listPath.replace(/^\//, ''), element: <EntityListPage module={config} /> },
{ path: `${config.baseUrl.replace(/^\//, '')}/addnew`, element: <EntityFormPage module={config} /> },
];
if (!config.skipAdd) {
routes.push({ path: `${config.baseUrl.replace(/^\//, '')}/addnew`, element: <EntityFormPage module={config} /> });
}
if (!config.skipEdit) {
routes.push({ path: `${config.baseUrl.replace(/^\//, '')}/edit`, element: <EntityFormPage module={config} /> });
}
@ -150,6 +174,28 @@ export const routes = [
...moduleRoutes(formexports),
...moduleRoutes(reminders),
...moduleRoutes(leaderboards),
{ path: '/leaderboard/dashboard', element: <LeaderboardDashboard /> },
...moduleRoutes(detailingcontests),
...moduleRoutes(contestparameters),
...moduleRoutes(detailingcontestresponses),
...moduleRoutes(contactlists),
...moduleRoutes(rcpaproducts),
...moduleRoutes(rcpaskus),
...moduleRoutes(rcpacompetitors),
...moduleRoutes(rcpaforms),
...moduleRoutes(rcpaproductattributes),
...moduleRoutes(brands),
...moduleRoutes(products),
...moduleRoutes(productattributes),
...moduleRoutes(formproducts),
...moduleRoutes(formpersonalizations),
...moduleRoutes(courses),
...moduleRoutes(lessons),
...moduleRoutes(articles),
...moduleRoutes(articlebanks),
...moduleRoutes(lmsquizs),
...moduleRoutes(sessions),
...moduleRoutes(tracks),
{ path: '/metricreports', element: <MetricReportList /> },
{ path: '/metricreport/report', element: <MetricReport /> },
{ path: '/coming-soon', element: <ComingSoon /> },

View File

@ -4,6 +4,7 @@ import { Button, Card, Container, Spinner, Table } from 'react-bootstrap';
import { signedGet, isSuccess } from '@/api/client';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import { ListTableFooter, ListTableToolbar } from '@/components/ListTableControls';
import { EmptyTableRow } from '@/components/EmptyState';
import { downloadCsv } from '@/helpers/csv';
import { formatRoleNames, getRoleMap, loadRoleMap } from '@/helpers/roles';
@ -137,11 +138,7 @@ export default function MagMediaViewers() {
</thead>
<tbody>
{pageRows.length === 0 ? (
<tr>
<td colSpan={COLUMNS.length} className="text-center text-muted">
No viewers
</td>
</tr>
<EmptyTableRow colSpan={COLUMNS.length} />
) : (
pageRows.map((user) => (
<tr key={user.id}>

View File

@ -1,10 +1,10 @@
import ReactApexChart from 'react-apexcharts';
import { CHART_COLORS } from '@/views/dashboard/helpers';
import EmptyState from '@/components/EmptyState';
const EMPTY_COLOR = '#e9ecef';
export function emptyPieChart(label = 'No data') {
return {
empty: true,
type: 'pie',
height: 250,
width: 250,
@ -23,6 +23,7 @@ export function emptyPieChart(label = 'No data') {
export function emptyRadialChart(label = 'No data', extras = {}) {
return {
empty: true,
type: 'radialBar',
height: 250,
series: [0],
@ -44,20 +45,7 @@ export function emptyRadialChart(label = 'No data', extras = {}) {
};
}
export default function EmptyChart({ type = 'radialBar', label = 'No data', height = 250 }) {
const chart = type === 'pie' ? emptyPieChart(label) : emptyRadialChart(label);
return (
<div className="widget-chart text-center empty-chart">
<div className="d-flex justify-content-center">
<ReactApexChart
className="apex-charts"
type={chart.type}
height={height}
width={chart.width || '100%'}
series={chart.series}
options={chart.options}
/>
</div>
</div>
);
export default function EmptyChart({ label = 'Not found' }) {
const description = !label || label === 'No data' ? 'No data to show' : label;
return <EmptyState title="Not found" description={description} />;
}

View File

@ -2,88 +2,12 @@ import { useMemo } from 'react';
import { Link } from 'react-router';
import { Card, Col } from 'react-bootstrap';
import ReactApexChart from 'react-apexcharts';
import { CHART_COLORS } from '@/views/dashboard/helpers';
import { emptyPieChart, emptyRadialChart } from '@/views/dashboard/EmptyChart';
import { buildDashboardMetricChart } from '@/views/metricreport/buildChart';
import { typeChartList } from '@/views/metricreport/helpers';
export default function MetricCard({ metric }) {
const chart = useMemo(() => {
const values = Array.isArray(metric.values) ? metric.values : [];
const hasPieData = values.some((row) => Number(row?.value) > 0);
const percent = Math.ceil(metric.max > 0 ? (Number(metric.value) / Number(metric.max)) * 100 : 0);
const isGrouped = metric.type === 'grouped';
const isVerses = metric.type === 'verses';
const isSimple = metric.type === 'simple';
const isTeam = metric.agg_type === 'team';
const isIndividual = metric.agg_type === 'individual';
if ((isIndividual || isTeam) && isGrouped) {
if (!hasPieData) {
return emptyPieChart(metric.label || 'No data');
}
return {
type: 'pie',
height: 250,
width: 250,
series: values.map((row) => Number(row.value) || 0),
options: {
chart: { type: 'pie', width: 250, animations: { enabled: true, easing: 'easeinout', speed: 2000 } },
labels: values.map((row) => row.label),
colors: CHART_COLORS,
legend: { show: false },
responsive: [{ breakpoint: 480, options: { chart: { width: 250 }, legend: { position: 'bottom' } } }],
},
};
}
if (isIndividual && isSimple) {
return {
type: 'radialBar',
height: 250,
series: [percent],
options: {
chart: { type: 'radialBar', height: 250, animations: { enabled: true, easing: 'easeinout', speed: 2000 } },
plotOptions: { radialBar: { startAngle: -90, endAngle: 270, hollow: { size: '58%' } } },
fill: {
type: 'gradient',
gradient: { shade: 'light', shadeIntensity: 0.15, inverseColors: false, opacityFrom: 1, opacityTo: 1, stops: [0, 50, 65, 91] },
},
stroke: { dashArray: 7 },
colors: CHART_COLORS,
labels: [metric.label || 'No data'],
legend: { show: false },
},
};
}
if (isTeam && (isSimple || isVerses)) {
return {
type: 'radialBar',
height: 250,
series: [percent],
options: {
chart: { type: 'radialBar', height: 250, animations: { enabled: true, easing: 'easeinout', speed: 2000 } },
plotOptions: {
radialBar: {
hollow: { size: '68%' },
...(isVerses
? { dataLabels: { name: { show: false }, value: { show: true, offsetY: 8, fontSize: '22px' } } }
: {}),
},
},
fill: {
type: 'gradient',
gradient: { shade: 'dark', shadeIntensity: 0.15, inverseColors: false, opacityFrom: 1, opacityTo: 1, stops: [0, 50, 65, 91] },
},
stroke: { dashArray: 4 },
colors: CHART_COLORS,
labels: [isVerses ? '' : metric.label || 'No data'],
legend: { show: false },
},
};
}
return isGrouped ? emptyPieChart(metric.label || 'No data') : emptyRadialChart(metric.label || 'No data');
}, [metric]);
const chart = useMemo(() => buildDashboardMetricChart(metric), [metric]);
const value = metric.value == null || metric.value === '' ? 0 : metric.value;
return (
<Col lg={4} className="mb-3">
@ -104,19 +28,23 @@ export default function MetricCard({ metric }) {
</div>
<h5 className="text-muted mt-0">{metric.label}</h5>
<h2>
{metric.value}
{metric.type === 'verses' ? ` / ${metric.max}` : ''}
{value}
{metric.type === 'verses' ? ` / ${metric.max ?? 0}` : ''}
</h2>
<div className="button-list">
{(metric.available_charts || []).map((item) => (
<Link
key={`${item.type}-${item.chart}`}
to={`/metricreport/report?id=${metric.id}&type=${item.type}&chart=${item.chart}`}
className="btn btn-soft-primary btn-sm rounded-pill me-1 mb-1"
>
{item.name} Chart
</Link>
))}
{(metric.available_charts || []).map((item) => {
const chartName = typeChartList(item)[0] || item.chart;
if (!item.type || !chartName) return null;
return (
<Link
key={`${item.type}-${chartName}`}
to={`/metricreport/report?id=${metric.id}&type=${item.type}&chart=${chartName}`}
className="btn btn-soft-primary btn-rounded me-1 mb-1"
>
{item.name} Chart
</Link>
);
})}
</div>
</div>
</Card.Body>

View File

@ -46,6 +46,19 @@ export function userInitials(name) {
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
}
export function rankRoleId(rankRole) {
if (!rankRole) return '';
const role = rankRole.role ?? rankRole.id;
if (role && typeof role === 'object') return String(role.id || role.role || '');
return String(role || '');
}
export function rankRoleName(rankRole) {
if (!rankRole) return '';
if (rankRole.role && typeof rankRole.role === 'object') return String(rankRole.role.name || '');
return String(rankRole.name || '');
}
export function rankRolePillLabel(title, limit, roleName) {
const label = String(title || '').trim() || String(roleName || '').trim() || 'Role';
if (/^\s*top\s+\d+/i.test(label)) return label;

View File

@ -4,6 +4,7 @@ import { Button, Card, Container, Row, Spinner } from 'react-bootstrap';
import { signedGet, isSuccess } from '@/api/client';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import MetricCard from '@/views/dashboard/MetricCard';
import EmptyState from '@/components/EmptyState';
import { resolveWidget } from '@/views/dashboard/widgets/registry';
function WidgetSlot({ widget }) {
@ -71,6 +72,7 @@ export default function Dashboard() {
</div>
) : (
<>
{metrics.length === 0 && widgets.length === 0 ? <EmptyState /> : null}
<Row>
{metrics.map((metric) => (
<MetricCard key={metric.id || metric.name} metric={metric} />

View File

@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router';
import { Spinner } from 'react-bootstrap';
import { signedGet, isSuccess } from '@/api/client';
import { asArray, rankRolePillLabel, userInitials } from '@/views/dashboard/helpers';
import { asArray, rankRoleId, rankRoleName, rankRolePillLabel, userInitials } from '@/views/dashboard/helpers';
import EmptyChart from '@/views/dashboard/EmptyChart';
function podiumUsersFromRankRole(rankRole) {
@ -126,11 +126,11 @@ export default function MilestoneTeamLeaderboard({ widget }) {
const mapped = leaderboards.map((board) => {
const rankRoles = asArray(board.rank_roles);
const pills = rankRoles.map((rankRole, index) => {
const role = rankRole.role || {};
const limit = Number(rankRole.limit) > 0 ? Number(rankRole.limit) : 10;
const roleId = rankRoleId(rankRole);
return {
label: rankRolePillLabel(rankRole.title, limit, role.name),
url: `/leaderboard/dashboard?id=${encodeURIComponent(board.id || '')}&role=${encodeURIComponent(role.id || '')}`,
label: rankRolePillLabel(rankRole.title, limit, rankRoleName(rankRole)),
url: `/leaderboard/dashboard?id=${encodeURIComponent(board.id || '')}&role=${encodeURIComponent(roleId)}`,
podium_users: podiumUsersFromRankRole(rankRole),
active: index === 0,
};

View File

@ -8,7 +8,7 @@ export default function VivaCompatitionPointsTable({ widget }) {
useEffect(() => {
const load = async () => {
const response = await signedGet('ums', 'team/list');
const response = await signedGet('detailing', 'team/list');
setTeams(isSuccess(response) ? response.data?.items || [] : []);
};
load();

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { Card, Col, Container, Modal, Row, Spinner } from 'react-bootstrap';
import { signedGet, isSuccess } from '@/api/client';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import EmptyState from '@/components/EmptyState';
export default function ConsoleList() {
const [actions, setActions] = useState([]);
@ -43,18 +44,20 @@ export default function ConsoleList() {
<Spinner animation="border" />
</div>
) : (
<Row>
<Row className="g-3">
{actions.length === 0 ? (
<Col xs={12}>
<p className="text-muted">No quick actions available.</p>
<EmptyState title="Not found" description="No quick actions available" />
</Col>
) : (
actions.map((action, index) => (
<Col sm={6} md={4} xl={3} key={`${action.form_id || action.name}-${index}`}>
<Card>
<Card className="h-100">
<Card.Body className="action-card" role="button" tabIndex={0} onClick={() => setActive(action)} onKeyDown={(event) => event.key === 'Enter' && setActive(action)}>
<img src={action.icon} alt={action.name} className="img-fluid rounded" width={200} />
<p className="mt-1 text-truncate mb-0">{action.name}</p>
<div className="action-card-icon">
<img src={action.icon} alt={action.name} />
</div>
<p className="action-card-title">{action.name}</p>
</Card.Body>
</Card>
</Col>

View File

@ -0,0 +1,307 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { Button, Card, Container, Form, Spinner, Table } from 'react-bootstrap';
import { signedGet, isSuccess } from '@/api/client';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import { downloadCsv } from '@/helpers/csv';
import { asArray, rankRoleId, rankRoleName, rankRolePillLabel } from '@/views/dashboard/helpers';
function uniquePeriods(periods) {
const seen = new Set();
return asArray(periods).filter((period) => {
const value = String(period?.value ?? '');
if (!value || seen.has(value)) return false;
seen.add(value);
return true;
});
}
function buildPills(leaderboardId, selectedRoleId, rankRoles, groups, period) {
let roles = asArray(rankRoles);
if (!roles.length) {
roles = asArray(groups).map((group) => group?.role || group);
}
roles = [...roles].sort((a, b) => Number(a?.sort_order || 0) - Number(b?.sort_order || 0));
return roles.map((rankRole, index) => {
const roleId = rankRoleId(rankRole);
const limit = Number(rankRole?.limit) > 0 ? Number(rankRole.limit) : 10;
const params = new URLSearchParams({ id: leaderboardId, role: roleId });
if (period) params.set('period', period);
return {
label: rankRolePillLabel(rankRole?.title, limit, rankRoleName(rankRole)),
to: `/leaderboard/dashboard?${params.toString()}`,
active: selectedRoleId ? roleId === selectedRoleId : index === 0,
roleId,
};
});
}
function shapeView({ id, roleId, period, dashboard, item }) {
const view = dashboard?.view || {};
const groups = asArray(dashboard?.leaderboard);
const periods = uniquePeriods(view.periods);
const selectedPeriod = String(period || view.interval || 'all') || 'all';
const pills = buildPills(id, roleId, item?.rank_roles, groups, period);
const selected =
groups.find((group) => {
const currentRoleId = rankRoleId(group?.role || group);
return !roleId || currentRoleId === roleId;
}) || null;
const selectedRole = selected ? rankRoleId(selected.role || selected) : roleId;
if (!selected) {
return {
error: 'No data found for this role.',
title: view.name || item?.name || 'Leaderboard',
periodLabel: '',
periods,
selectedPeriod,
users: [],
metrics: [],
pills,
selectedRole,
};
}
const roleConfig = selected.role || {};
const limit = Number(roleConfig.limit) > 0 ? Number(roleConfig.limit) : 0;
const roleTitle = String(roleConfig.title || '').trim() || 'Role';
const rows = asArray(selected.users);
const limited = limit > 0 ? rows.slice(0, limit) : rows;
const metrics = [];
const users = limited.map((row, index) => {
const info = row?.user || {};
const metricValues = {};
asArray(row?.metrics).forEach((metric, metricIndex) => {
const metricId = metric?.metric || metric?.title || `m${metricIndex}`;
const metricName = metric?.name || metric?.display || metric?.title || metricId;
if (index === 0) metrics.push({ id: metricId, name: metricName });
metricValues[metricId] = metric?.value ?? 0;
});
return {
rank: row?.rank ?? index + 1,
name: info.name || '',
avatar: info.avatar || '',
employee_code: info.employee_code || '',
designation: info.designation || '',
position_name: info.position_name || '',
score: row?.score ?? 0,
metric_values: metricValues,
};
});
const firstDay = view.firstDay || '';
const lastDay = view.lastDay || '';
const periodLabel = `${firstDay}${firstDay && lastDay ? ' – ' : ''}${lastDay}`.trim();
return {
error: '',
title: view.name || item?.name || 'Leaderboard',
periodLabel,
periods,
selectedPeriod,
users,
metrics,
pills,
selectedRole,
};
}
export default function LeaderboardDashboard() {
const [searchParams, setSearchParams] = useSearchParams();
const id = String(searchParams.get('id') || '').trim();
const roleId = String(searchParams.get('role') || '').trim();
const period = String(searchParams.get('period') || '').trim();
const [view, setView] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
const load = async () => {
if (!id) {
setView(null);
setError('Leaderboard is required.');
setLoading(false);
return;
}
setLoading(true);
setError('');
try {
const params = { id };
if (period) params.period = period;
const [dashboardRes, oneRes] = await Promise.all([
signedGet('reports', 'roles_leaderboard/dashboard', params, { timeout: 120000 }),
signedGet('reports', 'roles_leaderboard/get_one', { id }),
]);
if (!isSuccess(dashboardRes)) {
setView(
shapeView({
id,
roleId,
period,
dashboard: {},
item: isSuccess(oneRes) ? oneRes.data?.item : null,
}),
);
setError(dashboardRes?.message || 'Unable to load leaderboard.');
return;
}
const next = shapeView({
id,
roleId,
period,
dashboard: dashboardRes.data || {},
item: isSuccess(oneRes) ? oneRes.data?.item : null,
});
setView(next);
setError(next.error || '');
} catch (err) {
setView(null);
setError(err.message || 'Unable to load leaderboard.');
} finally {
setLoading(false);
}
};
load();
}, [id, roleId, period]);
const title = view?.title || 'Leaderboard';
const periods = view?.periods || [];
const pills = view?.pills || [];
const users = view?.users || [];
const metrics = view?.metrics || [];
const exportRows = useMemo(() => {
const header = ['Rank', 'User', 'Employee Code', 'Designation', 'Position', 'Score', ...metrics.map((metric) => metric.name)];
const body = users.map((user) => [
user.rank,
user.name,
user.employee_code,
user.designation,
user.position_name,
user.score,
...metrics.map((metric) => user.metric_values?.[metric.id] ?? ''),
]);
return [header, ...body];
}, [users, metrics]);
const onPeriodChange = (value) => {
const next = new URLSearchParams(searchParams);
if (value) next.set('period', value);
else next.delete('period');
setSearchParams(next, { replace: true });
};
const onExport = () => {
const filename = `${String(title || 'leaderboard')
.replace(/[^A-Za-z0-9_\-]+/g, '-')
.replace(/^-+|-+$/g, '') || 'leaderboard'}.csv`;
downloadCsv(exportRows, filename);
};
return (
<Container fluid className="mlb-dashboard">
<PageBreadcrumb title={title} subtitle="Leaderboard" />
<div className="d-flex justify-content-end gap-2 mb-3">
<Link to="/" className="btn btn-light btn-sm">
Back to Dashboard
</Link>
{!error && users.length ? (
<Button variant="success" size="sm" onClick={onExport}>
Export
</Button>
) : null}
</div>
{view?.periodLabel ? <p className="text-muted mb-3">{view.periodLabel}</p> : null}
<Card>
<Card.Body>
{pills.length || periods.length ? (
<div className="mlb-toolbar">
<div className="mlb-pills">
{pills.map((pill) => (
<Link key={pill.roleId || pill.label} className={`mlb-pill ${pill.active ? 'is-active' : ''}`} to={pill.to}>
{pill.label}
</Link>
))}
</div>
{periods.length ? (
<div className="mlb-period">
<label htmlFor="leaderboard-period">Period</label>
<Form.Select
id="leaderboard-period"
size="sm"
value={view?.selectedPeriod || ''}
onChange={(event) => onPeriodChange(event.target.value)}
>
{periods.map((item) => (
<option key={item.value} value={item.value}>
{item.label || item.value}
</option>
))}
</Form.Select>
</div>
) : null}
</div>
) : null}
{loading ? (
<div className="text-center py-4">
<Spinner animation="border" size="sm" />
</div>
) : error ? (
<p className="text-danger mb-0">{error}</p>
) : !users.length ? (
<p className="text-muted mb-0">No leaderboard data found.</p>
) : (
<div className="table-responsive">
<Table hover bordered className="table-centered table-nowrap mb-0">
<thead className="table-light">
<tr>
<th>Rank</th>
<th>User</th>
<th>Employee Code</th>
<th>Designation / Position</th>
<th>Score</th>
{metrics.map((metric) => (
<th key={metric.id}>{metric.name}</th>
))}
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={`${user.employee_code}-${user.rank}`}>
<td>{user.rank}</td>
<td>
<div className="d-flex align-items-center">
{user.avatar ? (
<img src={user.avatar} alt="" className="rounded-circle me-2" width={32} height={32} />
) : null}
<span>{user.name || '-'}</span>
</div>
</td>
<td>{user.employee_code || '-'}</td>
<td>
{user.designation || '-'}
{user.position_name ? (
<>
<br />
<small className="text-muted">{user.position_name}</small>
</>
) : null}
</td>
<td>{user.score}</td>
{metrics.map((metric) => (
<td key={metric.id}>{user.metric_values?.[metric.id] ?? '—'}</td>
))}
</tr>
))}
</tbody>
</Table>
</div>
)}
</Card.Body>
</Card>
</Container>
);
}

View File

@ -5,6 +5,7 @@ import { TbChartBar } from 'react-icons/tb';
import { signedGet, isSuccess } from '@/api/client';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import { ListTableFooter, ListTableToolbar } from '@/components/ListTableControls';
import { EmptyTableRow } from '@/components/EmptyState';
function formatDate(value) {
if (!value) return '-';
@ -96,11 +97,7 @@ export default function MetricReportList() {
</thead>
<tbody>
{pageRows.length === 0 ? (
<tr>
<td colSpan={6} className="text-center text-muted">
No records
</td>
</tr>
<EmptyTableRow colSpan={6} />
) : (
pageRows.map((item) => (
<tr key={item.id}>

View File

@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { Button, Card, Col, Container, Form, Modal, Row, Spinner } from 'react-bootstrap';
import Flatpickr from 'react-flatpickr';
import { TbFilter } from 'react-icons/tb';
import { TbFilter, TbArrowRight } from 'react-icons/tb';
import { LuCalendar, LuChartColumnIncreasing, LuUsers } from 'react-icons/lu';
import { signedGet, signedPost, isSuccess } from '@/api/client';
import PageBreadcrumb from '@/components/PageBreadcrumb';
@ -14,11 +14,15 @@ import {
canExport,
normalizeLeaderboardMeRow,
parseMetricsParam,
readBreadcrumbs,
titleCase,
chartTabLabel,
typeChartList,
toIsoDate,
uniqueBy,
updateBreadcrumbs,
writeBreadcrumbs,
isTeamReport,
isMyTeamType,
} from '@/views/metricreport/helpers';
import { loadRoleMap } from '@/helpers/roles';
@ -40,6 +44,7 @@ export default function MetricReport() {
const [selectedMetrics, setSelectedMetrics] = useState([]);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [pickingCustomDate, setPickingCustomDate] = useState(searchParams.get('period') === 'custom-date');
const [refreshToken, setRefreshToken] = useState(0);
const [roleMapVersion, setRoleMapVersion] = useState(0);
const refreshNext = useRef(searchParams.get('refresh') === '1' || searchParams.get('refresh') === 'true');
@ -80,6 +85,13 @@ export default function MetricReport() {
return;
}
if (query.period === 'custom-date' && !query.dates) {
setLoading(false);
setError('');
setPickingCustomDate(true);
return;
}
const load = async () => {
setLoading(true);
setError('');
@ -103,16 +115,12 @@ export default function MetricReport() {
}
const post = { ...(response.data.post || {}), ...body };
let graph = normalizeLeaderboardMeRow(response.data.graph);
let graph = normalizeLeaderboardMeRow(response.data.graph, post);
graph = applyMetricVisibility(graph, parseMetricsParam(post.metrics));
let crumbs = [];
if (graph.agg_type === 'team' && query.hid) {
if (isTeamReport(graph) && isMyTeamType(post.type) && query.hid) {
crumbs = updateBreadcrumbs(query.id, query.level || graph.current_level, query.hid, query.username);
} else if (graph.agg_type === 'team') {
crumbs = Object.entries(readBreadcrumbs(query.id))
.sort((a, b) => Number(b[0]) - Number(a[0]))
.map(([, value]) => value);
}
const codes = asArray(graph.contact_attributes);
@ -154,14 +162,17 @@ export default function MetricReport() {
const onPeriodChange = (value) => {
if (value === 'custom-date') {
updateQuery({ period: value });
setPickingCustomDate(true);
setError('');
return;
}
setPickingCustomDate(false);
updateQuery({ period: value }, ['dates']);
};
const onCustomDate = () => {
if (!startDate || !endDate) return;
setPickingCustomDate(false);
updateQuery({ period: 'custom-date', dates: `${startDate}--${endDate}` });
};
@ -187,15 +198,18 @@ export default function MetricReport() {
const graph = payload?.graph;
const post = payload?.post || query;
const periods = payload?.periods || [];
const periods = uniqueBy(payload?.periods, (item) => item?.value);
const charts = payload?.charts || [];
const currentType = post.type || query.type;
const currentCharts = asArray(charts).find((item) => item.type === currentType)?.charts || [];
const typeTabs = uniqueBy(
asArray(charts).filter((item) => typeChartList(item).length > 0),
(item) => item.type,
);
const currentCharts = typeChartList(typeTabs.find((item) => item.type === currentType));
return (
<Container fluid className="metric-report-page">
<PageBreadcrumb title="Metric Report" subtitle="Metric" />
<h4 className="page-title mb-3">Metric Report</h4>
{error ? <div className="alert alert-danger">{error}</div> : null}
{loading || !graph ? (
<div className="text-center py-5">
@ -233,43 +247,41 @@ export default function MetricReport() {
</p>
</Col>
<Col xs={4} className="text-end">
<Button
size="sm"
variant="info"
className="me-1 mb-1"
onClick={() => {
refreshNext.current = true;
setRefreshToken((value) => value + 1);
}}
>
Refresh Data
</Button>
{canExport(post, graph) ? (
<Button size="sm" variant="dark" className="me-1 mb-1" onClick={() => exportMetricReport({ graph, post, periods, labels })}>
Export
<div className="metric-report-actions">
<Button
variant="info"
className="btn-xs"
onClick={() => {
refreshNext.current = true;
setRefreshToken((value) => value + 1);
}}
>
Refresh Data
</Button>
) : null}
<Button size="sm" variant="success" className="mb-1" onClick={() => setFilterOpen(true)}>
<TbFilter />
</Button>
{canExport(post, graph) ? (
<Button variant="dark" className="btn-xs" onClick={() => exportMetricReport({ graph, post, periods, labels })}>
Export
</Button>
) : null}
<Button variant="success" className="btn-xs metric-report-filter" onClick={() => setFilterOpen(true)}>
<TbFilter />
</Button>
</div>
</Col>
<Col md={8}>
<div className="button-list mb-3">
{asArray(charts)
.filter((item) => asArray(item.charts).length > 0)
.map((item) => (
<div className="button-list">
{typeTabs.map((item) => (
<Link
key={item.type}
to={`/metricreport/report?${buildSearch({
...query,
id: query.id,
type: item.type,
chart: item.charts[0],
refresh: '',
hid: '',
username: '',
level: '',
chart: typeChartList(item)[0],
period: query.period || post.period,
dates: (query.period || post.period) === 'custom-date' ? query.dates : '',
metrics: query.metrics,
})}`}
className={`btn btn-sm rounded-pill me-1 mb-1 ${item.type === currentType ? 'btn-soft-warning' : 'btn-soft-primary'}`}
className={`btn btn-rounded ${item.type === currentType ? 'btn-soft-warning' : 'btn-soft-primary'}`}
>
{item.name}
</Link>
@ -282,7 +294,7 @@ export default function MetricReport() {
Period
</label>
<div className="col-9">
<Form.Select id="period" value={post.period || query.period || ''} onChange={(event) => onPeriodChange(event.target.value)}>
<Form.Select id="period" value={pickingCustomDate ? 'custom-date' : post.period || query.period || ''} onChange={(event) => onPeriodChange(event.target.value)}>
{asArray(periods).map((item) => (
<option key={item.value} value={item.value}>
{item.label}
@ -290,7 +302,7 @@ export default function MetricReport() {
))}
<option value="custom-date">Custom Date</option>
</Form.Select>
{(post.period || query.period) === 'custom-date' ? (
{pickingCustomDate || (post.period || query.period) === 'custom-date' ? (
<div className="input-group input-group-sm mt-2">
<Flatpickr
className="form-control border"
@ -304,9 +316,9 @@ export default function MetricReport() {
options={{ dateFormat: 'Y-m-d' }}
onChange={([date]) => setEndDate(date ? toIsoDate(date) : '')}
/>
<Button variant="primary" onClick={onCustomDate}>
Go
</Button>
<button type="button" className="input-group-text bg-primary border-primary text-white custom-date-go" onClick={onCustomDate}>
<TbArrowRight />
</button>
</div>
) : null}
</div>
@ -315,14 +327,24 @@ export default function MetricReport() {
</Row>
{currentCharts.length > 1 ? (
<div className="button-list mb-3">
<div className="button-list">
{currentCharts.map((chart) => (
<Link
key={chart}
to={`/metricreport/report?${buildSearch({ ...query, type: currentType, chart, refresh: '' })}`}
className={`btn btn-sm rounded-pill me-1 mb-1 ${chart === (post.chart || query.chart) ? 'btn-warning' : 'btn-soft-warning'}`}
to={`/metricreport/report?${buildSearch({
id: query.id,
type: currentType,
chart,
period: query.period || post.period,
dates: (query.period || post.period) === 'custom-date' ? query.dates : '',
metrics: query.metrics,
hid: query.hid,
username: query.username,
level: query.level,
})}`}
className={`btn btn-rounded ${chart === (post.chart || query.chart) ? 'btn-warning' : 'btn-soft-warning'}`}
>
{titleCase(chart)}
{chartTabLabel(chart)}
</Link>
))}
</div>
@ -354,7 +376,7 @@ export default function MetricReport() {
<Modal.Title>Filter out Metrics</Modal.Title>
</Modal.Header>
<Modal.Body>
{asArray(graph?.metrics).map((metric) => (
{uniqueBy(graph?.metrics, (metric) => metric?.metric).map((metric) => (
<Form.Check
key={metric.metric}
type="checkbox"
@ -368,10 +390,10 @@ export default function MetricReport() {
}}
/>
))}
<Button size="sm" variant="success" className="me-1" onClick={applyFilter}>
<Button variant="success" className="btn-xs me-1" onClick={applyFilter}>
Apply Filter
</Button>
<Button size="sm" variant="info" onClick={clearFilter}>
<Button variant="info" className="btn-xs" onClick={clearFilter}>
Clear Filter
</Button>
</Modal.Body>

View File

@ -1,7 +1,7 @@
import { Button, Dropdown, DropdownButton } from 'react-bootstrap';
import { TbChevronLeft, TbChevronRight, TbGitFork } from 'react-icons/tb';
import { TbChevronRight, TbGitFork } from 'react-icons/tb';
import ReactApexChart from 'react-apexcharts';
import EmptyChart from '@/views/dashboard/EmptyChart';
import { EmptyTableRow } from '@/components/EmptyState';
import { asArray } from '@/views/dashboard/helpers';
import { buildApexChart, isTableChart, showValueSummary, showVersesTrendTable } from '@/views/metricreport/buildChart';
import {
@ -10,69 +10,84 @@ import {
formatIncrementalHeader,
incrementalDates,
incrementalStat,
isLeaderboardView,
isMyTeamType,
isTeamReport,
leaderboardRows,
leaderboardStatValue,
objectGet,
objectValues,
positionValue,
rankAvatar,
roleLabel,
showPositions,
visibleMetrics,
} from '@/views/metricreport/helpers';
function RankBadge({ rank, index }) {
const value = rank == null || rank === '' ? index + 1 : rank;
const place = Number(value);
const highlight = place >= 1 && place <= 3 ? ` rank-${place}` : '';
return <span className={`rank-badge${highlight}`}>{value}</span>;
}
function ReportTable({ children }) {
return (
<div className="table-responsive pt-3">
<div className="table-responsive pt-3 text-start">
<table className="table table-centered table-nowrap table-borderless mb-0">{children}</table>
</div>
);
}
function LeaderboardTable({ graph, post, breadcrumbs, onLevel, onChilds, onResetTeam }) {
const isTeam = graph.agg_type === 'team';
const isLeaderboard = post.type === 'leaderboard';
const showChilds = isLeaderboard && isTeam && Number(graph.current_level) > 1;
const showRank = !isTeam || post.type !== 'leaderboard';
const isTeam = isTeamReport(graph);
const isMyTeam = isMyTeamType(post.type);
const currentLevel = Number(graph.current_level) || 1;
const maxLevel = Number(graph.level) || currentLevel;
const showChilds = isMyTeam && isTeam && currentLevel > 1;
const showRank = !isTeam || !isMyTeam;
const metrics = visibleMetrics(graph.metrics);
const rows = asArray(graph.values);
const rows = leaderboardRows(graph);
let lastId = '';
return (
<div className="pt-3">
{isTeam ? (
breadcrumbs.length === 0 ? (
<div className="d-flex justify-content-center gap-2 mb-2 flex-wrap">
<div className="btn-group">
<Button variant="info" disabled={Number(graph.current_level) === Number(graph.level)} onClick={() => onLevel(Number(graph.current_level) + 1)}>
<TbChevronLeft />
</Button>
<Button variant="info">Level {graph.current_level}</Button>
<Button variant="info" disabled={Number(graph.current_level) === 1} onClick={() => onLevel(Number(graph.current_level) - 1)}>
<TbChevronRight />
</Button>
</div>
<DropdownButton variant="warning" title="Select Level">
{Array.from({ length: Number(graph.level) || 0 }, (_, index) => Number(graph.level) - index).map((level) => (
<Dropdown.Item key={level} onClick={() => onLevel(level)}>
Level {level}
</Dropdown.Item>
))}
</DropdownButton>
<div className="level-filter">
<span className="level-filter-label">Level</span>
{Array.from({ length: maxLevel || 0 }, (_, index) => maxLevel - index).map((level) => (
<button
key={level}
type="button"
className={`level-filter-btn${level === currentLevel ? ' is-active' : ''}`}
onClick={() => onLevel(level)}
>
{level}
</button>
))}
</div>
) : (
<ol className="breadcrumb m-2 justify-content-center">
<li className="breadcrumb-item">
<button type="button" className="btn btn-link p-0" onClick={onResetTeam}>
<ol className="team-trail">
<li className="team-trail-item">
<button type="button" onClick={onResetTeam}>
Your Team
</button>
</li>
{breadcrumbs.map((crumb) => (
<li key={`${crumb.level}-${crumb.hid}`} className="breadcrumb-item">
<button type="button" className="btn btn-link p-0" onClick={() => onChilds(crumb.hid, crumb.username, crumb.level)}>
{crumb.username}
</button>
</li>
))}
{breadcrumbs.map((crumb, index) => {
const isCurrent = index === breadcrumbs.length - 1;
return (
<li key={`${crumb.level}-${crumb.hid}`} className="team-trail-item">
<TbChevronRight className="team-trail-sep" aria-hidden="true" />
{isCurrent ? (
<span className="is-current">{crumb.username}</span>
) : (
<button type="button" onClick={() => onChilds(crumb.hid, crumb.username, crumb.level)}>
{crumb.username}
</button>
)}
</li>
);
})}
</ol>
)
) : null}
@ -89,14 +104,17 @@ function LeaderboardTable({ graph, post, breadcrumbs, onLevel, onChilds, onReset
<th>Region</th>
<th>Area</th>
<th>Territory</th>
{graph.type === 'verses' ? null : <th>Active User</th>}
<th>Active User</th>
{metrics.map((metric) => (
<th key={metric.metric || metric.display}>{metric.display}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => {
{rows.length === 0 ? (
<EmptyTableRow colSpan={(showChilds ? 1 : 0) + (showRank ? 1 : 0) + 9 + metrics.length} />
) : (
rows.map((row, index) => {
const uid = row.user?.id;
if (uid && uid === lastId) return null;
lastId = uid;
@ -104,14 +122,20 @@ function LeaderboardTable({ graph, post, breadcrumbs, onLevel, onChilds, onReset
<tr key={`${uid || index}-${row.rank || index}`}>
{showChilds ? (
<td>
<Button size="sm" variant="primary" title="See Childs" onClick={() => onChilds(row.hierarchy || row.user?.hierarchy, row.user?.name, Number(graph.current_level) - 1)}>
<TbGitFork className="text-white" />
</Button>
<button
type="button"
className="btn btn-sm btn-soft-primary"
title="View"
onClick={() => onChilds(row.hierarchy || row.user?.hierarchy, row.user?.name, Number(graph.current_level) - 1)}
>
<TbGitFork className="me-1" />
View
</button>
</td>
) : null}
{showRank ? (
<td>
<img src={rankAvatar(row.rank, index)} alt="" className="avatar-md" />
<RankBadge rank={row.rank} index={index} />
</td>
) : null}
<td>
@ -120,18 +144,19 @@ function LeaderboardTable({ graph, post, breadcrumbs, onLevel, onChilds, onReset
<td className="text-start">{row.user?.name || ''}</td>
<td>{row.user?.employee_code || '-'}</td>
<td>{row.user?.designation || '-'}</td>
<td>{positionValue(row.user, 'Zone')}</td>
<td>{positionValue(row.user, 'Zone', { countryFallback: true })}</td>
<td>{positionValue(row.user, 'Region')}</td>
<td>{positionValue(row.user, 'Area')}</td>
<td>{positionValue(row.user, 'Territory')}</td>
{graph.type === 'verses' ? null : <td>{row.stats?.ucount ?? 0}</td>}
<td>{graph.type === 'verses' ? '-' : (row.stats?.ucount ?? 0)}</td>
{metrics.map((metric) => {
const value = leaderboardStatValue(row.stats, metric);
return <td key={`${uid}-${metric.metric || metric.display}`}>{value !== null && value !== undefined ? value : '-'}</td>;
})}
</tr>
);
})}
})
)}
</tbody>
</ReportTable>
</div>
@ -148,12 +173,16 @@ function SimpleTrendTable({ graph }) {
</tr>
</thead>
<tbody>
{asArray(graph.values).map((row, index) => (
{asArray(graph.values).length === 0 ? (
<EmptyTableRow colSpan={2} />
) : (
asArray(graph.values).map((row, index) => (
<tr key={`${row.x}-${index}`}>
<td>{row.x}</td>
<td>{row.y}</td>
</tr>
))}
))
)}
</tbody>
</ReportTable>
);
@ -172,14 +201,18 @@ function GroupedTrendTable({ graph }) {
</tr>
</thead>
<tbody>
{asArray(metrics[0]?.stats).map((stat, index) => (
{asArray(metrics[0]?.stats).length === 0 ? (
<EmptyTableRow colSpan={1 + metrics.length} />
) : (
asArray(metrics[0]?.stats).map((stat, index) => (
<tr key={`${stat.x}-${index}`}>
<td>{stat.x}</td>
{metrics.map((metric) => (
<td key={`${metric.metric}-${index}`}>{metric.stats?.[index]?.y ?? 0}</td>
))}
</tr>
))}
))
)}
</tbody>
</ReportTable>
);
@ -197,71 +230,79 @@ function VersesTrendTable({ graph, withPercent }) {
</tr>
</thead>
<tbody>
{asArray(graph.values?.[0]?.stats).map((row, index) => (
{asArray(graph.values?.[0]?.stats).length === 0 ? (
<EmptyTableRow colSpan={withPercent ? 4 : 3} />
) : (
asArray(graph.values?.[0]?.stats).map((row, index) => (
<tr key={`${row.x}-${index}`}>
<td>{row.x}</td>
<td>{row.actual}</td>
<td>{row.target}</td>
{withPercent ? <td>{row.y}%</td> : null}
</tr>
))}
))
)}
</tbody>
</ReportTable>
);
}
function DetailsTable({ graph }) {
const metrics = asArray(graph.values?.metrics);
const metrics = visibleMetrics(graph.values?.metrics);
const users = objectValues(graph.values?.users);
const positions = showPositions(graph);
return (
<ReportTable>
<thead className="table-light">
<tr>
<th colSpan={2}>User</th>
<th>Employee Code</th>
{positions ? (
<>
<th>Zone</th>
<th>Region</th>
<th>Area</th>
<th>Territory</th>
</>
) : null}
<th>Role</th>
{metrics.map((metric) => (
<th key={metric.metric}>{metric.metric}</th>
))}
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>
<img src={avatarUrl(user.avatar, user.name)} alt="" className="avatar-sm rounded-circle" />
</td>
<td>
{user.name || '-'}
<br />
{user.designation || '-'}
</td>
<td>{user.employee_code || ''}</td>
<thead className="table-light">
<tr>
<th colSpan={2}>User</th>
<th>Employee Code</th>
{positions ? (
<>
<td>{positionValue(user, 'Zone')}</td>
<td>{positionValue(user, 'Region')}</td>
<td>{positionValue(user, 'Area')}</td>
<td>{positionValue(user, 'Territory')}</td>
<th>Zone</th>
<th>Region</th>
<th>Area</th>
<th>Territory</th>
</>
) : null}
<td>{roleLabel(user.roles)}</td>
<th>Role</th>
{metrics.map((metric) => (
<td key={`${user.id}-${metric.metric}`}>{objectGet(metric.stats, user.id) ?? '-'}</td>
<th key={metric.metric || metric.display}>{metric.metric || metric.display}</th>
))}
</tr>
))}
</tbody>
</ReportTable>
</thead>
<tbody>
{users.length === 0 ? (
<EmptyTableRow colSpan={3 + (positions ? 4 : 0) + 1 + metrics.length} />
) : (
users.map((user) => (
<tr key={user.id}>
<td>
<img src={avatarUrl(user.avatar, user.name)} alt="" className="avatar-sm rounded-circle" />
</td>
<td>
{user.name || '-'}
<br />
{user.designation || '-'}
</td>
<td>{user.employee_code || ''}</td>
{positions ? (
<>
<td>{positionValue(user, 'Zone')}</td>
<td>{positionValue(user, 'Region')}</td>
<td>{positionValue(user, 'Area')}</td>
<td>{positionValue(user, 'Territory')}</td>
</>
) : null}
<td>{roleLabel(user.roles)}</td>
{metrics.map((metric) => (
<td key={`${user.id}-${metric.metric || metric.display}`}>{objectGet(metric.stats, user.id) ?? '-'}</td>
))}
</tr>
))
)}
</tbody>
</ReportTable>
);
}
@ -270,146 +311,155 @@ function ContactsTable({ graph, labels }) {
const attrs = asArray(graph.contact_attributes);
return (
<ReportTable>
<thead className="table-light">
<tr>
<th>Doctor</th>
<th>MSL Code</th>
{attrs.map((attr) => (
<th key={attr}>{attributeLabel(attr, labels)}</th>
))}
<th>User</th>
<th>Employee Code</th>
<th>Zone</th>
<th>Region</th>
<th>Area</th>
<th>Territory</th>
{metrics.map((metric) => (
<th key={metric.metric}>{metric.metric}</th>
))}
</tr>
</thead>
<tbody>
{asArray(graph.values?.contacts).map((contact) => {
const user = objectGet(graph.values?.users, contact.id);
return (
<tr key={contact.id || contact.msl_code}>
<td>{contact.name}</td>
<td>{contact.msl_code}</td>
{attrs.map((attr) => (
<td key={`${contact.id}-${attr}`}>{contact[attr] ?? ''}</td>
))}
{user ? (
<>
<td>
{user.name}
<br />
{user.designation}
</td>
<td>{user.employee_code || ''}</td>
<td>{positionValue(user, 'Zone')}</td>
<td>{positionValue(user, 'Region')}</td>
<td>{positionValue(user, 'Area')}</td>
<td>{positionValue(user, 'Territory')}</td>
</>
) : (
<>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
</>
)}
{metrics.map((metric) => (
<td key={`${contact.id}-${metric.metric}`}>{objectGet(metric.stats, contact.id) ?? '-'}</td>
))}
</tr>
);
})}
</tbody>
</ReportTable>
<thead className="table-light">
<tr>
<th>Doctor</th>
<th>MSL Code</th>
{attrs.map((attr) => (
<th key={attr}>{attributeLabel(attr, labels)}</th>
))}
<th>User</th>
<th>Employee Code</th>
<th>Zone</th>
<th>Region</th>
<th>Area</th>
<th>Territory</th>
{metrics.map((metric) => (
<th key={metric.metric || metric.display}>{metric.metric || metric.display}</th>
))}
</tr>
</thead>
<tbody>
{asArray(graph.values?.contacts).length === 0 ? (
<EmptyTableRow colSpan={2 + attrs.length + 6 + metrics.length} />
) : (
asArray(graph.values?.contacts).map((contact) => {
const user = objectGet(graph.values?.users, contact.id);
return (
<tr key={contact.id || contact.msl_code}>
<td>{contact.name}</td>
<td>{contact.msl_code}</td>
{attrs.map((attr) => (
<td key={`${contact.id}-${attr}`}>{contact[attr] ?? ''}</td>
))}
{user ? (
<>
<td>
{user.name}
<br />
{user.designation}
</td>
<td>{user.employee_code || ''}</td>
<td>{positionValue(user, 'Zone')}</td>
<td>{positionValue(user, 'Region')}</td>
<td>{positionValue(user, 'Area')}</td>
<td>{positionValue(user, 'Territory')}</td>
</>
) : (
<>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td>-</td>
</>
)}
{metrics.map((metric) => (
<td key={`${contact.id}-${metric.metric || metric.display}`}>{objectGet(metric.stats, contact.id) ?? '-'}</td>
))}
</tr>
);
})
)}
</tbody>
</ReportTable>
);
}
function IncrementalTable({ graph, post }) {
const dates = incrementalDates(graph.first_day, graph.last_day, post.period);
const metrics = asArray(graph.values?.metrics);
const metrics = visibleMetrics(graph.values?.metrics);
const users = objectValues(graph.values?.users);
const positions = showPositions(graph);
return (
<ReportTable>
<thead className="table-light">
<tr>
<th colSpan={2} rowSpan={2}>
User
</th>
<th rowSpan={2}>Employee Code</th>
{positions ? (
<>
<th rowSpan={2}>Zone</th>
<th rowSpan={2}>Region</th>
<th rowSpan={2}>Area</th>
<th rowSpan={2}>Territory</th>
</>
) : null}
<th rowSpan={2}>Role</th>
{metrics.map((metric) => (
<th key={metric.metric} colSpan={dates.length} style={{ borderLeft: '1px solid #ccc' }}>
{metric.metric}
<thead className="table-light">
<tr>
<th colSpan={2} rowSpan={2}>
User
</th>
))}
</tr>
<tr>
{metrics.map((metric) =>
dates.map((date) => (
<th key={`${metric.metric}-${date.getTime()}`}>{formatIncrementalHeader(date)}</th>
)),
)}
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>
<img src={avatarUrl(user.avatar, user.name)} alt="" className="avatar-sm rounded-circle" />
</td>
<td>
{user.name}
<br />
{user.designation}
</td>
<td>{user.employee_code || ''}</td>
<th rowSpan={2}>Employee Code</th>
{positions ? (
<>
<td>{positionValue(user, 'Zone')}</td>
<td>{positionValue(user, 'Region')}</td>
<td>{positionValue(user, 'Area')}</td>
<td>{positionValue(user, 'Territory')}</td>
<th rowSpan={2}>Zone</th>
<th rowSpan={2}>Region</th>
<th rowSpan={2}>Area</th>
<th rowSpan={2}>Territory</th>
</>
) : null}
<td>{roleLabel(user.roles)}</td>
<th rowSpan={2}>Role</th>
{metrics.map((metric) => (
<th key={metric.metric || metric.display} colSpan={dates.length} style={{ borderLeft: '1px solid #ccc' }}>
{metric.metric || metric.display}
</th>
))}
</tr>
<tr>
{metrics.map((metric) =>
dates.map((date) => (
<td key={`${user.id}-${metric.metric}-${date.getTime()}`}>{incrementalStat(metric, user.id, date)}</td>
<th key={`${metric.metric || metric.display}-${date.getTime()}`}>{formatIncrementalHeader(date)}</th>
)),
)}
</tr>
))}
</tbody>
</ReportTable>
</thead>
<tbody>
{users.length === 0 ? (
<EmptyTableRow colSpan={3 + (positions ? 4 : 0) + 1 + metrics.length * Math.max(dates.length, 1)} />
) : (
users.map((user) => (
<tr key={user.id}>
<td>
<img src={avatarUrl(user.avatar, user.name)} alt="" className="avatar-sm rounded-circle" />
</td>
<td>
{user.name}
<br />
{user.designation}
</td>
<td>{user.employee_code || ''}</td>
{positions ? (
<>
<td>{positionValue(user, 'Zone')}</td>
<td>{positionValue(user, 'Region')}</td>
<td>{positionValue(user, 'Area')}</td>
<td>{positionValue(user, 'Territory')}</td>
</>
) : null}
<td>{roleLabel(user.roles)}</td>
{metrics.map((metric) =>
dates.map((date) => (
<td key={`${user.id}-${metric.metric || metric.display}-${date.getTime()}`}>{incrementalStat(metric, user.id, date)}</td>
)),
)}
</tr>
))
)}
</tbody>
</ReportTable>
);
}
function ApexView({ graph, post }) {
const chart = buildApexChart(graph, post);
const summary = showValueSummary(post, graph);
const isValueTab = post?.type === 'value';
return (
<div className="widget-chart text-center">
{chart ? (
{isValueTab ? null : chart && !chart.empty ? (
<ReactApexChart type={chart.type} height={chart.height} width={chart.width || '100%'} series={chart.series} options={chart.options} />
) : (
<EmptyChart type="radialBar" label="No data" />
<EmptyChart />
)}
{summary === 'single' ? (
<>
@ -433,40 +483,36 @@ function ApexView({ graph, post }) {
export default function ReportViews({ graph, post, breadcrumbs, labels, onLevel, onChilds, onResetTeam }) {
const type = post?.type;
const chart = graph?.chart || post?.chart;
const hasRows =
(chart === 'leaderboard' && asArray(graph.values).length) ||
(type === 'trend' && chart === 'tabular' && asArray(graph.values).length) ||
(type === 'details' && objectValues(graph.values?.users).length) ||
(type === 'contacts' && asArray(graph.values?.contacts).length) ||
(type === 'incremental' && objectValues(graph.values?.users).length);
const hasTrendRows = asArray(graph.values).length > 0;
const isContactsView = chart === 'contacts';
const isIncrementalView = chart === 'incremental';
const isDetailsTabular = chart === 'tabular' && type === 'details';
if (chart === 'leaderboard') {
return hasRows ? (
if (isLeaderboardView(post, graph)) {
return (
<LeaderboardTable graph={graph} post={post} breadcrumbs={breadcrumbs} onLevel={onLevel} onChilds={onChilds} onResetTeam={onResetTeam} />
) : (
<EmptyChart type="radialBar" label="No data" />
);
}
if (graph.type === 'simple' && type === 'trend' && chart === 'tabular') {
return hasRows ? <SimpleTrendTable graph={graph} /> : <EmptyChart type="radialBar" label="No data" />;
return hasTrendRows ? <SimpleTrendTable graph={graph} /> : <EmptyChart />;
}
if (graph.type === 'grouped' && type === 'trend' && chart === 'tabular') {
return hasRows ? <GroupedTrendTable graph={graph} /> : <EmptyChart type="radialBar" label="No data" />;
return hasTrendRows ? <GroupedTrendTable graph={graph} /> : <EmptyChart />;
}
if (type === 'details' && chart === 'tabular') {
return graph.values?.metrics && hasRows ? <DetailsTable graph={graph} /> : <EmptyChart type="radialBar" label="No data" />;
if (isDetailsTabular) {
return graph.values?.metrics ? <DetailsTable graph={graph} /> : <EmptyChart />;
}
if (type === 'contacts' && chart === 'contacts') {
return graph.values?.metrics && hasRows ? <ContactsTable graph={graph} labels={labels} /> : <EmptyChart type="radialBar" label="No data" />;
if (isContactsView) {
return graph.values?.metrics ? <ContactsTable graph={graph} labels={labels} /> : <EmptyChart />;
}
if (type === 'trend' && graph.type === 'verses' && chart === 'tabular') {
return asArray(graph.values?.[0]?.stats).length ? <VersesTrendTable graph={graph} withPercent /> : <EmptyChart type="radialBar" label="No data" />;
return asArray(graph.values?.[0]?.stats).length ? <VersesTrendTable graph={graph} withPercent /> : <EmptyChart />;
}
if (type === 'incremental' && chart === 'incremental') {
return graph.values?.metrics && hasRows ? <IncrementalTable graph={graph} post={post} /> : <EmptyChart type="radialBar" label="No data" />;
if (isIncrementalView) {
return graph.values?.metrics ? <IncrementalTable graph={graph} post={post} /> : <EmptyChart />;
}
if (isTableChart(post, graph)) {
return <EmptyChart type="radialBar" label="No data" />;
return <EmptyChart />;
}
return <ApexView graph={graph} post={post} />;
}

View File

@ -1,6 +1,6 @@
import { emptyPieChart, emptyRadialChart } from '@/views/dashboard/EmptyChart';
import { asArray } from '@/views/dashboard/helpers';
import { REPORT_CHART_COLORS } from '@/views/metricreport/helpers';
import { REPORT_CHART_COLORS, isLeaderboardView } from '@/views/metricreport/helpers';
const ANIMATION = { enabled: true, easing: 'easeinout', speed: 2000 };
@ -29,9 +29,6 @@ function trendOptions(type, series, categories) {
function radialOptions(series, label, extras = {}) {
const values = asArray(series).map((item) => Number(item) || 0);
if (!values.some((item) => item > 0)) {
return emptyRadialChart(label || 'No data');
}
return {
type: 'radialBar',
height: 250,
@ -60,14 +57,81 @@ function radialOptions(series, label, extras = {}) {
export function isTableChart(post, graph) {
const type = post?.type;
const chart = graph?.chart || post?.chart;
if (chart === 'leaderboard') return true;
if (type === 'trend' && chart === 'tabular') return true;
if (type === 'details' && chart === 'tabular') return true;
if (type === 'contacts' && chart === 'contacts') return true;
if (type === 'incremental' && chart === 'incremental') return true;
if (isLeaderboardView(post, graph) || chart === 'leaderboard' || chart === 'scoreboard' || chart === 'myteam') return true;
if (chart === 'contacts' || chart === 'incremental') return true;
if (chart === 'tabular' && (type === 'trend' || type === 'details')) return true;
return false;
}
function groupedPieRows(metric) {
const fromValues = asArray(metric?.values).filter(
(row) => row && typeof row === 'object' && !row.user && (row.value != null || row.label != null || row.display != null),
);
if (fromValues.some((row) => Number(row.value) > 0)) return fromValues;
const fromMetrics = asArray(metric?.metrics).map((row) => ({
label: row.display || row.label || row.metric,
value: row.value ?? row.total ?? row.y,
}));
if (fromMetrics.some((row) => Number(row.value) > 0)) return fromMetrics;
return fromValues;
}
export function buildDashboardMetricChart(metric) {
const values = groupedPieRows(metric);
const hasPieData = values.some((row) => Number(row?.value) > 0);
const percent = percentOf(metric?.value, metric?.max);
const kind = metric?.type;
const isGrouped = kind === 'grouped';
const isVerses = kind === 'verses';
const isSimple = kind === 'simple';
const isTeam = metric?.agg_type === 'team';
const isIndividual = metric?.agg_type === 'individual';
if ((isIndividual || isTeam) && isGrouped && hasPieData) {
return {
type: 'pie',
height: 250,
width: 250,
series: values.map((row) => Number(row.value) || 0),
options: {
chart: { type: 'pie', width: 250, animations: ANIMATION },
labels: values.map((row) => row.label || row.display || row.metric),
colors: REPORT_CHART_COLORS,
legend: { show: false },
responsive: [{ breakpoint: 480, options: { chart: { width: 250 }, legend: { position: 'bottom' } } }],
},
};
}
const teamRadial = isTeam && (isSimple || isVerses || isGrouped || !isIndividual);
const emptyRing = percent === 0;
return {
type: 'radialBar',
height: 250,
series: [percent],
options: {
chart: { type: 'radialBar', height: 250, animations: ANIMATION },
plotOptions: {
radialBar: {
...(teamRadial && !isIndividual ? { hollow: { size: '68%' } } : { startAngle: -90, endAngle: 270, hollow: { size: '58%' } }),
dataLabels: {
name: { show: !emptyRing && !isVerses },
value: { show: true, offsetY: emptyRing || isVerses ? 8 : 0, fontSize: emptyRing || isVerses ? '22px' : undefined },
},
},
},
fill: {
type: 'gradient',
gradient: { shade: teamRadial && !isIndividual ? 'dark' : 'light', shadeIntensity: 0.15, inverseColors: false, opacityFrom: 1, opacityTo: 1, stops: [0, 50, 65, 91] },
},
stroke: { dashArray: teamRadial && !isIndividual ? 4 : 7 },
colors: REPORT_CHART_COLORS,
labels: [isVerses ? '' : metric?.label || ''],
legend: { show: false },
},
};
}
export function buildApexChart(graph, post) {
const type = post?.type;
const chart = post?.chart || graph?.chart;

View File

@ -3,6 +3,9 @@ import {
formatIncrementalHeader,
incrementalDates,
incrementalStat,
isMyTeamType,
isTeamReport,
leaderboardRows,
leaderboardStatValue,
objectGet,
objectValues,
@ -39,21 +42,22 @@ function parentSample(values) {
}
function buildLeaderboardRows(graph, post) {
const hideRank = graph.agg_type === 'team' && post.type === 'leaderboard';
const values = leaderboardRows(graph);
const hideRank = isTeamReport(graph) && isMyTeamType(post.type);
const headings = hideRank
? ['User Id', 'User Name', 'Employee Code', 'Designation']
: ['Rank', 'User Id', 'User Name', 'Employee Code', 'Designation'];
const parents = parentSample(graph.values);
const parents = parentSample(values);
parents.forEach((parent) => {
headings.push(`Manager ${parent.level} Name`, `Manager ${parent.level} Emp Code`);
});
headings.push('Zone', 'Region', 'Area', 'Territory', 'Active User');
const first = asArray(graph.values)[0];
const first = values[0];
if (first?.stats?.prescribers != null) headings.push('Prescribers');
asArray(graph.metrics).forEach((metric) => headings.push(metric.display));
const rows = [headings];
asArray(graph.values).forEach((row) => {
values.forEach((row) => {
const user = row.user || {};
const data = hideRank
? [user.id, user.name, user.employee_code, user.designation]
@ -63,7 +67,7 @@ function buildLeaderboardRows(graph, post) {
const parent = userParents[index] || {};
data.push(parent.name || '-', parent.employee_code || '-');
});
data.push(positionValue(user, 'Zone'), positionValue(user, 'Region'), positionValue(user, 'Area'), positionValue(user, 'Territory'));
data.push(positionValue(user, 'Zone', { countryFallback: true }), positionValue(user, 'Region'), positionValue(user, 'Area'), positionValue(user, 'Territory'));
data.push(row.stats?.ucount ?? 0);
if (first?.stats?.prescribers != null) data.push(row.stats?.prescribers ?? 0);
asArray(graph.metrics).forEach((metric) => data.push(leaderboardStatValue(row.stats, metric)));
@ -256,6 +260,7 @@ export function exportMetricReport({ graph, post, periods, labels }) {
switch (post.type) {
case 'leaderboard':
case 'scoreboard':
case 'myteam':
body = buildLeaderboardRows(graph, post);
break;
case 'trend':
@ -265,7 +270,13 @@ export function exportMetricReport({ graph, post, periods, labels }) {
body = graph.type === 'grouped' ? buildGraphicalRows(graph) : [];
break;
case 'details':
body = graph.chart === 'tabular' ? buildDetailsRows(graph) : [];
if (graph.chart === 'contacts' || post.chart === 'contacts') {
body = buildContactsRows(graph, labels);
} else if (graph.chart === 'incremental' || post.chart === 'incremental') {
body = buildIncrementalRows(graph, post);
} else {
body = graph.chart === 'tabular' || post.chart === 'tabular' ? buildDetailsRows(graph) : [];
}
break;
case 'contacts':
body = graph.chart === 'contacts' ? buildContactsRows(graph, labels) : [];

View File

@ -1,7 +1,6 @@
import { asArray } from '@/views/dashboard/helpers';
import { formatRoleNames } from '@/helpers/roles';
export const RANK_COLORS = ['FECD00', '01C14B', 'FF8050', '4A5CD0', '707070'];
export const REPORT_CHART_COLORS = ['#01C14B', '#FF8050', '#4A5CD0', '#FECD00', '#707070'];
export function parseMetricsParam(value) {
@ -32,9 +31,68 @@ export function applyMetricVisibility(graph, selectedMetrics) {
return next;
}
export function normalizeLeaderboardMeRow(graph) {
if (graph?.chart !== 'leaderboard') return graph;
const values = asArray(graph.values);
export function uniqueBy(list, keyFn) {
const seen = new Set();
return asArray(list).filter((item, index) => {
const key = String(keyFn(item, index) ?? '');
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function typeChartList(item) {
if (!item) return [];
const raw = item.charts ?? item.chart;
if (raw == null || raw === '') return [];
let list = [];
if (Array.isArray(raw)) list = raw.filter((part) => part != null && part !== '');
else if (typeof raw === 'string') {
list = raw
.split(',')
.map((part) => part.trim())
.filter(Boolean);
} else if (typeof raw === 'object') {
list = Object.values(raw).flatMap((part) => typeChartList({ charts: part }));
} else list = [raw];
return uniqueBy(list, (part) => part);
}
export function isTeamReport(graph) {
return String(graph?.agg_type || '').toLowerCase() === 'team';
}
export function isLeaderboardType(type) {
return ['leaderboard', 'scoreboard', 'myteam', 'team'].includes(String(type || '').toLowerCase());
}
export function isLeaderboardView(post, graph) {
const type = String(post?.type || '').toLowerCase();
const chart = String(graph?.chart || post?.chart || '').toLowerCase();
if (['leaderboard', 'scoreboard', 'myteam'].includes(chart)) return true;
if (isLeaderboardType(type) && !['line', 'bar', 'pie', 'progress', 'percentile'].includes(chart)) return true;
return false;
}
export function isMyTeamType(type) {
const value = String(type || '').toLowerCase();
return value === 'leaderboard' || value === 'myteam';
}
export function leaderboardRows(graph) {
const values = graph?.values;
if (!values) return [];
let source = [];
if (Array.isArray(values)) source = values;
else if (Array.isArray(values.list)) source = values.list;
else if (Array.isArray(values.users) && values.users[0]?.user) source = values.users;
else if (typeof values === 'object') source = Object.values(values);
return source.filter((row) => row && typeof row === 'object' && row.user);
}
export function normalizeLeaderboardMeRow(graph, post) {
if (!isLeaderboardView(post, graph) && graph?.chart !== 'leaderboard') return graph;
const values = leaderboardRows(graph);
if (!values.length) return graph;
const first = values[0];
const name = first?.user?.name || '';
@ -68,7 +126,10 @@ export function leaderboardStatValue(stats, metric) {
}
export function visibleMetrics(list) {
return asArray(list).filter((metric) => metric?.is_visible !== false);
return uniqueBy(
asArray(list).filter((metric) => metric?.is_visible !== false),
(metric) => metric?.metric || metric?.display,
);
}
export function avatarUrl(src, name) {
@ -76,11 +137,6 @@ export function avatarUrl(src, name) {
return `https://ui-avatars.com/api/?background=random&name=${encodeURIComponent(name || '')}`;
}
export function rankAvatar(rank, index) {
const color = index > 3 ? RANK_COLORS[4] : RANK_COLORS[index] || RANK_COLORS[4];
return `https://ui-avatars.com/api/?background=${color}&name=${encodeURIComponent(rank ?? '')}`;
}
export function showPositions(graph) {
const value = graph?.show_positions;
if (value === undefined || value === null) return true;
@ -89,10 +145,10 @@ export function showPositions(graph) {
return text !== '' && text !== 'false' && text !== 'no' && text !== '0';
}
export function positionValue(user, key) {
export function positionValue(user, key, { countryFallback = false } = {}) {
const positions = user?.positions || {};
if (positions[key]) return positions[key];
if (key === 'Zone' && positions.Country) return positions.Country;
if (countryFallback && key === 'Zone' && positions.Country) return positions.Country;
return '-';
}
@ -108,15 +164,32 @@ export function titleCase(value) {
.replace(/\b\w/g, (char) => char.toUpperCase());
}
export function chartTabLabel(chart) {
const labels = {
tabular: 'Tabular',
contacts: 'Doctor View',
incremental: 'Incremental View',
leaderboard: 'Leaderboard',
progress: 'Progress',
percentile: 'Percentile',
pie: 'Pie',
bar: 'Bar',
line: 'Line',
scoreboard: 'Leaderboard',
myteam: 'My Team',
};
return labels[chart] || titleCase(chart);
}
export function canExport(post, graph) {
const type = post?.type;
const chart = post?.chart || graph?.chart;
if (['trend', 'leaderboard'].includes(type) && !['line', 'bar'].includes(chart)) return true;
if (['trend', 'leaderboard', 'myteam'].includes(type) && !['line', 'bar'].includes(chart)) return true;
if (type === 'graphical' && graph?.type === 'grouped') return true;
if (type === 'details' && graph?.chart === 'tabular') return true;
if (type === 'contacts' && graph?.chart === 'contacts') return true;
if (type === 'scoreboard' && graph?.chart === 'leaderboard') return true;
if (type === 'incremental' && graph?.chart === 'incremental') return true;
if (type === 'details' && chart === 'tabular') return true;
if ((type === 'contacts' || type === 'details') && chart === 'contacts') return true;
if (type === 'scoreboard' || isLeaderboardView(post, graph)) return true;
if ((type === 'incremental' || type === 'details') && chart === 'incremental') return true;
return false;
}

View File

@ -1,5 +1,5 @@
import EntityForm from '@/components/EntityForm';
export default function EntityFormPage({ module }) {
return <EntityForm module={module} />;
return <EntityForm key={module.baseUrl || module.module} module={module} />;
}

View File

@ -1,5 +1,5 @@
import EntityList from '@/components/EntityList';
export default function EntityListPage({ module }) {
return <EntityList module={module} />;
return <EntityList key={module.listPath || module.module} module={module} />;
}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Link } from 'react-router';
import { Button, Card, Col, Container, Form, Modal, Row, Spinner } from 'react-bootstrap';
import { TbArrowsMove, TbChevronDown, TbChevronRight, TbPencil, TbPlus, TbTrash } from 'react-icons/tb';
@ -15,9 +15,24 @@ function ownerLabel(node) {
return `${name} (${info.employee_code || '-'})`;
}
function nodeCanExpand(_node, loadedKids) {
if (Array.isArray(loadedKids)) return loadedKids.length > 0;
return true;
function isInnermostLevel(node) {
const type = String(node?.type || '').trim().toLowerCase();
return type === 'territory' || type === 'pool territory' || type === 'tm';
}
function nodeCanExpand(node) {
return !isInnermostLevel(node);
}
function collectExpanded(nodes, children, rootId) {
const next = {};
Object.entries(children || {}).forEach(([id, kids]) => {
if (!kids?.length) return;
if (isInnermostLevel(nodes[id])) return;
next[id] = true;
});
if (rootId) next[rootId] = true;
return next;
}
function ingestFullTree(items, me) {
@ -58,10 +73,130 @@ const emptyChild = {
const CHILDREN_PARAMS = { children: 1, source: 'elastic' };
function createExpandStore() {
let state = {};
const listeners = new Map();
const notify = (id) => {
listeners.get(id)?.forEach((fn) => fn(Boolean(state[id])));
};
return {
isOpen: (id) => Boolean(state[id]),
collapse(id) {
if (!state[id]) return;
state = { ...state };
delete state[id];
notify(id);
},
expand(id) {
if (state[id]) return;
state = { ...state, [id]: true };
notify(id);
},
replace(next) {
const prev = state;
state = { ...(next || {}) };
const ids = new Set([...Object.keys(prev), ...Object.keys(state)]);
ids.forEach((id) => notify(id));
},
subscribe(id, fn) {
if (!listeners.has(id)) listeners.set(id, new Set());
listeners.get(id).add(fn);
return () => {
const set = listeners.get(id);
if (!set) return;
set.delete(fn);
if (!set.size) listeners.delete(id);
};
},
};
}
function useIsExpanded(store, id) {
const [open, setOpen] = useState(() => store.isOpen(id));
useEffect(() => store.subscribe(id, setOpen), [store, id]);
return open;
}
const HierarchyContext = createContext(null);
const NodeList = memo(function NodeList({ parentId }) {
const { childrenById, nodesById } = useContext(HierarchyContext);
const childIds = childrenById[parentId];
if (!childIds?.length) return null;
return (
<ul className="list-unstyled ps-3 border-start ms-2 mt-1 mb-0 d-flex flex-column gap-1">
{childIds.map((id) => {
const node = nodesById[id];
if (!node) return null;
return (
<li key={id}>
<NodeRow node={node} />
</li>
);
})}
</ul>
);
});
const NodeRow = memo(function NodeRow({ node }) {
const { loadingIds, expandStore, actions } = useContext(HierarchyContext);
const isExpanded = useIsExpanded(expandStore, node.id);
const keepKids = useRef(isExpanded);
if (isExpanded) keepKids.current = true;
const isLoadingNode = Boolean(loadingIds[node.id]);
const canExpand = nodeCanExpand(node);
return (
<>
<div className="d-flex align-items-center justify-content-between gap-2 p-2 bg-light rounded">
<div className="d-flex align-items-center gap-2 min-w-0">
{canExpand ? (
<Button
size="sm"
variant="soft-secondary"
className="flex-shrink-0"
title={isExpanded ? 'Collapse' : 'Expand'}
onClick={() => actions.toggleExpand(node.id)}
>
{isLoadingNode ? (
<Spinner animation="border" size="sm" />
) : isExpanded ? (
<TbChevronDown className="fs-lg" />
) : (
<TbChevronRight className="fs-lg" />
)}
</Button>
) : null}
<div>
<strong>{node.name}</strong> ({node.type}) — {ownerLabel(node)}
</div>
</div>
<div className="d-flex gap-1">
<Button size="sm" variant="soft-secondary" title="Add child" onClick={() => actions.openAdd(node)}>
<TbPlus />
</Button>
<Button size="sm" variant="soft-info" title="Move" onClick={() => actions.openMove(node)}>
<TbArrowsMove />
</Button>
<Button size="sm" variant="soft-warning" title="Edit" onClick={() => actions.openEdit(node)}>
<TbPencil />
</Button>
<Button size="sm" variant="soft-danger" title="Vacate" onClick={() => actions.onVacate(node)}>
<TbTrash />
</Button>
</div>
</div>
{keepKids.current ? (
<div hidden={!isExpanded}>
<NodeList parentId={node.id} />
</div>
) : null}
</>
);
});
export default function HierarchyTree() {
const [nodesById, setNodesById] = useState({});
const [childrenById, setChildrenById] = useState({});
const [expanded, setExpanded] = useState({});
const [loadingIds, setLoadingIds] = useState({});
const [rootId, setRootId] = useState('');
const [positions, setPositions] = useState({});
@ -69,6 +204,7 @@ export default function HierarchyTree() {
const [roles, setRoles] = useState({});
const [channels, setChannels] = useState({});
const [loading, setLoading] = useState(true);
const [loadingAll, setLoadingAll] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
@ -80,6 +216,9 @@ export default function HierarchyTree() {
const [moveForm, setMoveForm] = useState({ id: '', parent_id: '', previous_owner: '', code: '' });
const lookupsLoaded = useRef(false);
const lookupsInflight = useRef(null);
const expandStore = useMemo(() => createExpandStore(), []);
const childrenByIdRef = useRef(childrenById);
childrenByIdRef.current = childrenById;
const isCrestroad = () => getSession()?.domain === 'crestroad';
@ -162,25 +301,25 @@ export default function HierarchyTree() {
setNodesById(ingested.nodes);
setChildrenById(ingested.children);
setRootId(ingested.rootId);
setExpanded(ingested.rootId ? { [ingested.rootId]: true } : {});
expandStore.replace(ingested.rootId ? { [ingested.rootId]: true } : {});
return;
}
if (!me?.id) {
setNodesById({});
setChildrenById({});
setRootId('');
setExpanded({});
expandStore.replace({});
return;
}
setRootId(me.id);
setExpanded({ [me.id]: true });
expandStore.replace({ [me.id]: true });
mergeChildren(me.id, items, [{ ...me, has_children: items.length > 0 }]);
} catch (err) {
setError(err.message || 'Unable to load hierarchy');
} finally {
setLoading(false);
}
}, [mergeChildren]);
}, [mergeChildren, expandStore]);
useEffect(() => {
loadTree();
@ -226,30 +365,29 @@ export default function HierarchyTree() {
[fetchChildren, loadTree],
);
const toggleExpand = async (id) => {
if (expanded[id]) {
setExpanded((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
return;
}
if (!isCrestroad() && childrenById[id] === undefined) {
await fetchChildren(id);
}
setExpanded((prev) => ({ ...prev, [id]: true }));
};
const toggleExpand = useCallback(
async (id) => {
if (expandStore.isOpen(id)) {
expandStore.collapse(id);
return;
}
if (!isCrestroad() && childrenByIdRef.current[id] === undefined) {
await fetchChildren(id);
}
expandStore.expand(id);
},
[expandStore, fetchChildren],
);
const openAdd = (node) => {
const openAdd = useCallback((node) => {
setChildTitle(`Add New Child for ${node.name}`);
setLockCode(false);
setChildForm({ ...emptyChild, parent_id: node.id });
setChildOpen(true);
loadLookups();
};
}, [loadLookups]);
const openEdit = (node) => {
const openEdit = useCallback((node) => {
setChildTitle(`Edit ${node.name}`);
setLockCode(true);
setChildForm({
@ -262,9 +400,9 @@ export default function HierarchyTree() {
});
setChildOpen(true);
loadLookups();
};
}, [loadLookups]);
const openMove = (node) => {
const openMove = useCallback((node) => {
setMoveForm({
id: node.id,
parent_id: node.parent_id,
@ -273,9 +411,9 @@ export default function HierarchyTree() {
});
setMoveOpen(true);
loadLookups();
};
}, [loadLookups]);
const onVacate = async (node) => {
const onVacate = useCallback(async (node) => {
if (!window.confirm(`Vacate "${node.name}"? This moves the position to vacant (not a hard delete).`)) return;
setSaving(true);
setError('');
@ -296,7 +434,7 @@ export default function HierarchyTree() {
} finally {
setSaving(false);
}
};
}, [refetchParents, rootId]);
const saveChild = async (event) => {
event.preventDefault();
@ -318,7 +456,7 @@ export default function HierarchyTree() {
setChildOpen(false);
setMessage('Successfully Saved !!!');
if (childForm.parent_id) {
setExpanded((prev) => ({ ...prev, [childForm.parent_id]: true }));
expandStore.expand(childForm.parent_id);
}
await refetchParents([childForm.parent_id]);
} catch (err) {
@ -415,6 +553,42 @@ export default function HierarchyTree() {
}
};
const onLoadAll = async () => {
setLoadingAll(true);
setError('');
setMessage('');
try {
if (isCrestroad()) {
expandStore.replace(collectExpanded(nodesById, childrenById, rootId));
setMessage('Hierarchy loaded');
return;
}
const response = await signedGet('ums', 'hierarchy/get_all', {}, { timeout: 120000 });
if (response?.statusCode === 401 || response?.statusCode === 403) {
setError('Unauthorised');
return;
}
if (!isSuccess(response)) {
setError(response?.message || 'Unable to load hierarchy');
return;
}
const ingested = ingestFullTree(response.data?.items || [], response.data?.me);
if (!ingested.rootId) {
setError('Unable to load hierarchy');
return;
}
setNodesById(ingested.nodes);
setChildrenById(ingested.children);
setRootId(ingested.rootId);
expandStore.replace(collectExpanded(ingested.nodes, ingested.children, ingested.rootId));
setMessage('Hierarchy loaded');
} catch (err) {
setError(err.message || 'Unable to load hierarchy');
} finally {
setLoadingAll(false);
}
};
const onRepopulate = async () => {
if (!window.confirm('Repopulate typed position data for this domain?')) return;
setSaving(true);
@ -426,7 +600,7 @@ export default function HierarchyTree() {
setMessage('Successfully Populated !!!');
setNodesById({});
setChildrenById({});
setExpanded({});
expandStore.replace({});
await loadTree();
} else {
setError(response?.message || 'Repopulate failed');
@ -438,75 +612,14 @@ export default function HierarchyTree() {
}
};
const NodeList = ({ parentId }) => {
const childIds = childrenById[parentId];
if (!childIds || !childIds.length) return null;
return (
<ul className="list-unstyled ps-3 border-start ms-2 mt-1 mb-0 d-flex flex-column gap-1">
{childIds.map((id) => {
const node = nodesById[id];
if (!node) return null;
return (
<li key={node.id}>
<NodeRow node={node} />
</li>
);
})}
</ul>
);
};
const NodeRow = ({ node }) => {
const isExpanded = Boolean(expanded[node.id]);
const isLoadingNode = Boolean(loadingIds[node.id]);
const loadedKids = childrenById[node.id];
const canExpand = nodeCanExpand(node, loadedKids);
return (
<>
<div className="d-flex align-items-center justify-content-between gap-2 p-2 bg-light rounded">
<div className="d-flex align-items-center gap-2 min-w-0">
{canExpand ? (
<Button
size="sm"
variant="soft-secondary"
className="flex-shrink-0"
title={isExpanded ? 'Collapse' : 'Expand'}
onClick={() => toggleExpand(node.id)}
>
{isLoadingNode ? (
<Spinner animation="border" size="sm" />
) : isExpanded ? (
<TbChevronDown className="fs-lg" />
) : (
<TbChevronRight className="fs-lg" />
)}
</Button>
) : (
<span className="d-inline-block flex-shrink-0" style={{ width: 32 }} />
)}
<div>
<strong>{node.name}</strong> ({node.type}) — {ownerLabel(node)}
</div>
</div>
<div className="d-flex gap-1">
<Button size="sm" variant="soft-secondary" title="Add child" onClick={() => openAdd(node)}>
<TbPlus />
</Button>
<Button size="sm" variant="soft-info" title="Move" onClick={() => openMove(node)}>
<TbArrowsMove />
</Button>
<Button size="sm" variant="soft-warning" title="Edit" onClick={() => openEdit(node)}>
<TbPencil />
</Button>
<Button size="sm" variant="soft-danger" title="Vacate" onClick={() => onVacate(node)}>
<TbTrash />
</Button>
</div>
</div>
{isExpanded ? <NodeList parentId={node.id} /> : null}
</>
);
};
const actions = useMemo(
() => ({ toggleExpand, openAdd, openMove, openEdit, onVacate }),
[toggleExpand, openAdd, openMove, openEdit, onVacate],
);
const treeValue = useMemo(
() => ({ nodesById, childrenById, loadingIds, expandStore, actions }),
[nodesById, childrenById, loadingIds, expandStore, actions],
);
const rootNode = rootId ? nodesById[rootId] : null;
@ -515,10 +628,13 @@ export default function HierarchyTree() {
<PageBreadcrumb title="Hierarchy Management" subtitle="Organisation" />
<div className="d-flex justify-content-end align-items-center mb-3 flex-wrap gap-2">
<div className="d-flex gap-2 flex-wrap">
<Button variant="dark" disabled={saving} onClick={onRepopulate}>
<Button variant="dark" disabled={saving || loadingAll} onClick={onLoadAll}>
{loadingAll ? 'Loading...' : 'Load All'}
</Button>
<Button variant="dark" disabled={saving || loadingAll} onClick={onRepopulate}>
Repopulate Positions
</Button>
<Button variant="dark" disabled={saving} onClick={onExport}>
<Button variant="dark" disabled={saving || loadingAll} onClick={onExport}>
Export
</Button>
<Link to="/hierarchy/import" className="btn btn-dark">
@ -537,7 +653,9 @@ export default function HierarchyTree() {
) : !rootNode ? (
<div className="text-muted">No hierarchy nodes</div>
) : (
<NodeRow node={rootNode} />
<HierarchyContext.Provider value={treeValue}>
<NodeRow node={rootNode} />
</HierarchyContext.Provider>
)}
</Card.Body>
</Card>

View File

@ -5,6 +5,7 @@ import { TbPencil, TbPlus, TbTrash } from 'react-icons/tb';
import { signedGet, signedPost, isSuccess } from '@/api/client';
import { dropdownMap } from '@/api/dropdowns';
import PageBreadcrumb from '@/components/PageBreadcrumb';
import EmptyState from '@/components/EmptyState';
function ownerLabel(node) {
if (node?.is_vacant) return 'Vacant';
@ -94,7 +95,7 @@ export default function TeamPlayers() {
setSaving(true);
setError('');
try {
const response = await signedGet('ums', 'team/player_delete', { id: node.id });
const response = await signedGet('detailing', 'team/player_delete', { id: node.id });
if (isSuccess(response)) {
setMessage('Successfully Marked Deleted !!!');
load();
@ -184,7 +185,7 @@ export default function TeamPlayers() {
<Spinner animation="border" />
</div>
) : (grouped.root || []).length === 0 ? (
<div className="text-muted">No players</div>
<EmptyState title="Not found" description="No players to show" />
) : (
<NodeList parentId="root" />
)}