Latest changes till 23 Sep 2026
This commit is contained in:
parent
41dd41b304
commit
b6275d214a
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
/>
|
||||
<>
|
||||
<div className="s3-file-field-preview">
|
||||
<div className="s3-file-field-render">
|
||||
{previewSrc ? (
|
||||
<img src={previewSrc} alt="" />
|
||||
) : (
|
||||
<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" />
|
||||
</span>
|
||||
</span>
|
||||
<FileExtensionWithPreview className="s3-file-field-icon" width={72} height={72} extension={extension} />
|
||||
)}
|
||||
<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 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>
|
||||
{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>
|
||||
</div>
|
||||
{uploading ? (
|
||||
<span className="s3-file-field-loader">
|
||||
<Spinner animation="border" size="sm" />
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className="btn btn-sm btn-default flex-shrink-0">
|
||||
Browse
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { dropdownMap } from '@/api/dropdowns';
|
||||
|
||||
const teams = {
|
||||
service: 'ums',
|
||||
service: 'detailing',
|
||||
getOneService: 'ums',
|
||||
module: 'team',
|
||||
title: 'Teams',
|
||||
baseUrl: '/team',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 /> },
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
{(metric.available_charts || []).map((item) => {
|
||||
const chartName = typeChartList(item)[0] || item.chart;
|
||||
if (!item.type || !chartName) return null;
|
||||
return (
|
||||
<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"
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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,10 +247,10 @@ export default function MetricReport() {
|
|||
</p>
|
||||
</Col>
|
||||
<Col xs={4} className="text-end">
|
||||
<div className="metric-report-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="info"
|
||||
className="me-1 mb-1"
|
||||
className="btn-xs"
|
||||
onClick={() => {
|
||||
refreshNext.current = true;
|
||||
setRefreshToken((value) => value + 1);
|
||||
|
|
@ -245,31 +259,29 @@ export default function MetricReport() {
|
|||
Refresh Data
|
||||
</Button>
|
||||
{canExport(post, graph) ? (
|
||||
<Button size="sm" variant="dark" className="me-1 mb-1" onClick={() => exportMetricReport({ graph, post, periods, labels })}>
|
||||
<Button variant="dark" className="btn-xs" onClick={() => exportMetricReport({ graph, post, periods, labels })}>
|
||||
Export
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="sm" variant="success" className="mb-1" onClick={() => setFilterOpen(true)}>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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>
|
||||
))}
|
||||
</DropdownButton>
|
||||
</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)}>
|
||||
{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,21 +230,25 @@ 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 (
|
||||
|
|
@ -230,12 +267,15 @@ function DetailsTable({ graph }) {
|
|||
) : null}
|
||||
<th>Role</th>
|
||||
{metrics.map((metric) => (
|
||||
<th key={metric.metric}>{metric.metric}</th>
|
||||
<th key={metric.metric || metric.display}>{metric.metric || metric.display}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
{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" />
|
||||
|
|
@ -256,10 +296,11 @@ function DetailsTable({ graph }) {
|
|||
) : null}
|
||||
<td>{roleLabel(user.roles)}</td>
|
||||
{metrics.map((metric) => (
|
||||
<td key={`${user.id}-${metric.metric}`}>{objectGet(metric.stats, user.id) ?? '-'}</td>
|
||||
<td key={`${user.id}-${metric.metric || metric.display}`}>{objectGet(metric.stats, user.id) ?? '-'}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</ReportTable>
|
||||
);
|
||||
|
|
@ -284,12 +325,15 @@ function ContactsTable({ graph, labels }) {
|
|||
<th>Area</th>
|
||||
<th>Territory</th>
|
||||
{metrics.map((metric) => (
|
||||
<th key={metric.metric}>{metric.metric}</th>
|
||||
<th key={metric.metric || metric.display}>{metric.metric || metric.display}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{asArray(graph.values?.contacts).map((contact) => {
|
||||
{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}>
|
||||
|
|
@ -322,11 +366,12 @@ function ContactsTable({ graph, labels }) {
|
|||
</>
|
||||
)}
|
||||
{metrics.map((metric) => (
|
||||
<td key={`${contact.id}-${metric.metric}`}>{objectGet(metric.stats, contact.id) ?? '-'}</td>
|
||||
<td key={`${contact.id}-${metric.metric || metric.display}`}>{objectGet(metric.stats, contact.id) ?? '-'}</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</ReportTable>
|
||||
);
|
||||
|
|
@ -334,7 +379,7 @@ function ContactsTable({ graph, labels }) {
|
|||
|
||||
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 (
|
||||
|
|
@ -355,21 +400,24 @@ function IncrementalTable({ graph, post }) {
|
|||
) : null}
|
||||
<th rowSpan={2}>Role</th>
|
||||
{metrics.map((metric) => (
|
||||
<th key={metric.metric} colSpan={dates.length} style={{ borderLeft: '1px solid #ccc' }}>
|
||||
{metric.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) => (
|
||||
<th key={`${metric.metric}-${date.getTime()}`}>{formatIncrementalHeader(date)}</th>
|
||||
<th key={`${metric.metric || metric.display}-${date.getTime()}`}>{formatIncrementalHeader(date)}</th>
|
||||
)),
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
{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" />
|
||||
|
|
@ -391,11 +439,12 @@ function IncrementalTable({ graph, post }) {
|
|||
<td>{roleLabel(user.roles)}</td>
|
||||
{metrics.map((metric) =>
|
||||
dates.map((date) => (
|
||||
<td key={`${user.id}-${metric.metric}-${date.getTime()}`}>{incrementalStat(metric, user.id, date)}</td>
|
||||
<td key={`${user.id}-${metric.metric || metric.display}-${date.getTime()}`}>{incrementalStat(metric, user.id, date)}</td>
|
||||
)),
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</ReportTable>
|
||||
);
|
||||
|
|
@ -404,12 +453,13 @@ function IncrementalTable({ graph, post }) {
|
|||
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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) : [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
const toggleExpand = useCallback(
|
||||
async (id) => {
|
||||
if (expandStore.isOpen(id)) {
|
||||
expandStore.collapse(id);
|
||||
return;
|
||||
}
|
||||
if (!isCrestroad() && childrenById[id] === undefined) {
|
||||
if (!isCrestroad() && childrenByIdRef.current[id] === undefined) {
|
||||
await fetchChildren(id);
|
||||
}
|
||||
setExpanded((prev) => ({ ...prev, [id]: true }));
|
||||
};
|
||||
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>
|
||||
const actions = useMemo(
|
||||
() => ({ toggleExpand, openAdd, openMove, openEdit, onVacate }),
|
||||
[toggleExpand, openAdd, openMove, openEdit, onVacate],
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
const treeValue = useMemo(
|
||||
() => ({ nodesById, childrenById, loadingIds, expandStore, actions }),
|
||||
[nodesById, childrenById, loadingIds, expandStore, actions],
|
||||
);
|
||||
};
|
||||
|
||||
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 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>
|
||||
) : (
|
||||
<HierarchyContext.Provider value={treeValue}>
|
||||
<NodeRow node={rootNode} />
|
||||
</HierarchyContext.Provider>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue