From b6275d214af255849147fc9cad14ecddf4051d2e Mon Sep 17 00:00:00 2001 From: Amey Bhagwat Date: Wed, 23 Sep 2026 12:44:51 +0530 Subject: [PATCH] Latest changes till 23 Sep 2026 --- src/api/client.js | 5 +- src/assets/scss/components/_forms.scss | 12 + src/assets/scss/pages/_dashboard.scss | 304 ++++++++++- src/assets/scss/plugins/_dropzone.scss | 184 +++++++ src/components/EmptyState.jsx | 31 ++ src/components/EntityForm.jsx | 62 ++- src/components/EntityList.jsx | 64 ++- src/components/PageBreadcrumb.jsx | 25 +- src/components/S3FileField.jsx | 113 ++-- src/components/table/DataTable.jsx | 9 +- src/helpers/metric.js | 7 + src/layouts/menu.js | 32 +- src/modules/articlebanks.js | 42 ++ src/modules/articles.js | 51 ++ src/modules/brands.js | 35 ++ src/modules/contactlists.js | 23 + src/modules/contestparameters.js | 24 + src/modules/courses.js | 45 ++ src/modules/detailingcontestresponses.js | 36 ++ src/modules/detailingcontests.js | 126 +++++ src/modules/formentitys.js | 1 + src/modules/formpersonalizations.js | 49 ++ src/modules/formproducts.js | 41 ++ src/modules/leaderboards.js | 2 +- src/modules/lessons.js | 55 ++ src/modules/lmsquizs.js | 29 ++ src/modules/productattributes.js | 35 ++ src/modules/products.js | 59 +++ src/modules/rcpacompetitors.js | 45 ++ src/modules/rcpaforms.js | 85 +++ src/modules/rcpaproductattributes.js | 85 +++ src/modules/rcpaproducts.js | 31 ++ src/modules/rcpaskus.js | 47 ++ src/modules/sessions.js | 95 ++++ src/modules/teams.js | 3 +- src/modules/tracks.js | 52 ++ src/modules/users.js | 27 + src/routes/index.jsx | 48 +- src/views/content/MagMediaViewers.jsx | 7 +- src/views/dashboard/EmptyChart.jsx | 24 +- src/views/dashboard/MetricCard.jsx | 110 +--- src/views/dashboard/helpers.js | 13 + src/views/dashboard/index.jsx | 2 + .../widgets/MilestoneTeamLeaderboard.jsx | 8 +- .../widgets/VivaCompatitionPointsTable.jsx | 2 +- src/views/formaction/ConsoleList.jsx | 13 +- src/views/leaderboard/Dashboard.jsx | 307 +++++++++++ src/views/metricreport/List.jsx | 7 +- src/views/metricreport/Report.jsx | 128 +++-- src/views/metricreport/ReportViews.jsx | 490 ++++++++++-------- src/views/metricreport/buildChart.js | 82 ++- src/views/metricreport/exportReport.js | 23 +- src/views/metricreport/helpers.js | 107 +++- src/views/modules/EntityFormPage.jsx | 2 +- src/views/modules/EntityListPage.jsx | 2 +- src/views/organisation/HierarchyTree.jsx | 328 ++++++++---- src/views/organisation/TeamPlayers.jsx | 5 +- 57 files changed, 2997 insertions(+), 682 deletions(-) create mode 100644 src/components/EmptyState.jsx create mode 100644 src/modules/articlebanks.js create mode 100644 src/modules/articles.js create mode 100644 src/modules/brands.js create mode 100644 src/modules/contactlists.js create mode 100644 src/modules/contestparameters.js create mode 100644 src/modules/courses.js create mode 100644 src/modules/detailingcontestresponses.js create mode 100644 src/modules/detailingcontests.js create mode 100644 src/modules/formpersonalizations.js create mode 100644 src/modules/formproducts.js create mode 100644 src/modules/lessons.js create mode 100644 src/modules/lmsquizs.js create mode 100644 src/modules/productattributes.js create mode 100644 src/modules/products.js create mode 100644 src/modules/rcpacompetitors.js create mode 100644 src/modules/rcpaforms.js create mode 100644 src/modules/rcpaproductattributes.js create mode 100644 src/modules/rcpaproducts.js create mode 100644 src/modules/rcpaskus.js create mode 100644 src/modules/sessions.js create mode 100644 src/modules/tracks.js create mode 100644 src/views/leaderboard/Dashboard.jsx diff --git a/src/api/client.js b/src/api/client.js index 1ba8d26..144683f 100644 --- a/src/api/client.js +++ b/src/api/client.js @@ -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', }; } } diff --git a/src/assets/scss/components/_forms.scss b/src/assets/scss/components/_forms.scss index dbdd8ea..4a3f374 100644 --- a/src/assets/scss/components/_forms.scss +++ b/src/assets/scss/components/_forms.scss @@ -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; diff --git a/src/assets/scss/pages/_dashboard.scss b/src/assets/scss/pages/_dashboard.scss index 8e0b9ba..8c4e2c2 100644 --- a/src/assets/scss/pages/_dashboard.scss +++ b/src/assets/scss/pages/_dashboard.scss @@ -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 { diff --git a/src/assets/scss/plugins/_dropzone.scss b/src/assets/scss/plugins/_dropzone.scss index 070cb69..3da7bca 100644 --- a/src/assets/scss/plugins/_dropzone.scss +++ b/src/assets/scss/plugins/_dropzone.scss @@ -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; + } +} diff --git a/src/components/EmptyState.jsx b/src/components/EmptyState.jsx new file mode 100644 index 0000000..1168333 --- /dev/null +++ b/src/components/EmptyState.jsx @@ -0,0 +1,31 @@ +function NotFoundIcon() { + return ( + + ); +} + +export default function EmptyState({ title = 'Not found', description = 'No data to show', size = 'md' }) { + return ( +
+ +

{title}

+ {description ?

{description}

: null} +
+ ); +} + +export function EmptyTableRow({ colSpan = 20, title = 'Not found', description = 'No data to show' }) { + return ( + + + + + + ); +} diff --git a/src/components/EntityForm.jsx b/src/components/EntityForm.jsx index 1cb68b2..11a47c6 100644 --- a/src/components/EntityForm.jsx +++ b/src/components/EntityForm.jsx @@ -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 { + if (!field.required) return true; + const selected = Array.isArray(value) ? value.filter(Boolean) : []; + return selected.length > 0 || `Select ${field.title}`; + }, + }} render={({ field: rhf }) => ( {({ getRootProps, getInputProps, isDragActive }) => (
-
- {hasFile ? ( - previewSrc ? ( - {displayName} - ) : ( - - ) - ) : ( - - - + {hasFile ? ( + <> +
+
+ {previewSrc ? ( + + ) : ( + + )} +
+
+
+

+ {displayName} +

+

+ {uploading ? 'Uploading...' : 'Drag and drop or click to replace'} +

+
+
+
+ {uploading ? ( + + -
- )} -
-
- {hasFile - ? displayName - : isImageField - ? 'Drop or click to upload an image' - : 'Drop or click to upload a file'} -
-
- {uploading - ? 'Uploading...' - : hasFile - ? localFile?.size - ? formatBytes(localFile.size) - : 'Uploaded' - : `${exts.join(', ')} · 500MB max`} -
+ ) : ( + + )} + + ) : ( +
+ +

Drag and drop a file here or click

- {hasFile ? ( - - ) : ( - - )} -
+ )}
)} - +

500MB Max File size

{error ?
{error}
: null}
); diff --git a/src/components/table/DataTable.jsx b/src/components/table/DataTable.jsx index 4d323e1..a237cd6 100644 --- a/src/components/table/DataTable.jsx +++ b/src/components/table/DataTable.jsx @@ -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 = ({ {table.getRowModel().rows?.length ? table.getRowModel().rows.map(row => {row.getVisibleCells().map(cell => {flexRender(cell.column.columnDef.cell, cell.getContext())})} - ) : - - {emptyMessage} - - } + ) : } ; diff --git a/src/helpers/metric.js b/src/helpers/metric.js index 4797f38..6023685 100644 --- a/src/helpers/metric.js +++ b/src/helpers/metric.js @@ -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); diff --git a/src/layouts/menu.js b/src/layouts/menu.js index edb1466..31b8953 100644 --- a/src/layouts/menu.js +++ b/src/layouts/menu.js @@ -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')) { diff --git a/src/modules/articlebanks.js b/src/modules/articlebanks.js new file mode 100644 index 0000000..ce648ae --- /dev/null +++ b/src/modules/articlebanks.js @@ -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; diff --git a/src/modules/articles.js b/src/modules/articles.js new file mode 100644 index 0000000..fde4869 --- /dev/null +++ b/src/modules/articles.js @@ -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; diff --git a/src/modules/brands.js b/src/modules/brands.js new file mode 100644 index 0000000..3373d70 --- /dev/null +++ b/src/modules/brands.js @@ -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; diff --git a/src/modules/contactlists.js b/src/modules/contactlists.js new file mode 100644 index 0000000..41fb7c6 --- /dev/null +++ b/src/modules/contactlists.js @@ -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; diff --git a/src/modules/contestparameters.js b/src/modules/contestparameters.js new file mode 100644 index 0000000..aa92e66 --- /dev/null +++ b/src/modules/contestparameters.js @@ -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; diff --git a/src/modules/courses.js b/src/modules/courses.js new file mode 100644 index 0000000..4646045 --- /dev/null +++ b/src/modules/courses.js @@ -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; diff --git a/src/modules/detailingcontestresponses.js b/src/modules/detailingcontestresponses.js new file mode 100644 index 0000000..adc88f6 --- /dev/null +++ b/src/modules/detailingcontestresponses.js @@ -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; diff --git a/src/modules/detailingcontests.js b/src/modules/detailingcontests.js new file mode 100644 index 0000000..99815f1 --- /dev/null +++ b/src/modules/detailingcontests.js @@ -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; diff --git a/src/modules/formentitys.js b/src/modules/formentitys.js index 52c67eb..a6ec097 100644 --- a/src/modules/formentitys.js +++ b/src/modules/formentitys.js @@ -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' }, diff --git a/src/modules/formpersonalizations.js b/src/modules/formpersonalizations.js new file mode 100644 index 0000000..b24b361 --- /dev/null +++ b/src/modules/formpersonalizations.js @@ -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; diff --git a/src/modules/formproducts.js b/src/modules/formproducts.js new file mode 100644 index 0000000..2a05471 --- /dev/null +++ b/src/modules/formproducts.js @@ -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; diff --git a/src/modules/leaderboards.js b/src/modules/leaderboards.js index 97fd655..7a4c2a6 100644 --- a/src/modules/leaderboards.js +++ b/src/modules/leaderboards.js @@ -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' }, diff --git a/src/modules/lessons.js b/src/modules/lessons.js new file mode 100644 index 0000000..5ce3998 --- /dev/null +++ b/src/modules/lessons.js @@ -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; diff --git a/src/modules/lmsquizs.js b/src/modules/lmsquizs.js new file mode 100644 index 0000000..87c2df6 --- /dev/null +++ b/src/modules/lmsquizs.js @@ -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; diff --git a/src/modules/productattributes.js b/src/modules/productattributes.js new file mode 100644 index 0000000..f4deb9d --- /dev/null +++ b/src/modules/productattributes.js @@ -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; diff --git a/src/modules/products.js b/src/modules/products.js new file mode 100644 index 0000000..9eee022 --- /dev/null +++ b/src/modules/products.js @@ -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; diff --git a/src/modules/rcpacompetitors.js b/src/modules/rcpacompetitors.js new file mode 100644 index 0000000..2cf076d --- /dev/null +++ b/src/modules/rcpacompetitors.js @@ -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; diff --git a/src/modules/rcpaforms.js b/src/modules/rcpaforms.js new file mode 100644 index 0000000..cf54750 --- /dev/null +++ b/src/modules/rcpaforms.js @@ -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; diff --git a/src/modules/rcpaproductattributes.js b/src/modules/rcpaproductattributes.js new file mode 100644 index 0000000..24e2212 --- /dev/null +++ b/src/modules/rcpaproductattributes.js @@ -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; diff --git a/src/modules/rcpaproducts.js b/src/modules/rcpaproducts.js new file mode 100644 index 0000000..a0b0089 --- /dev/null +++ b/src/modules/rcpaproducts.js @@ -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; diff --git a/src/modules/rcpaskus.js b/src/modules/rcpaskus.js new file mode 100644 index 0000000..7a8a8cf --- /dev/null +++ b/src/modules/rcpaskus.js @@ -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; diff --git a/src/modules/sessions.js b/src/modules/sessions.js new file mode 100644 index 0000000..08e7aa2 --- /dev/null +++ b/src/modules/sessions.js @@ -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; diff --git a/src/modules/teams.js b/src/modules/teams.js index 4a28c77..0edd236 100644 --- a/src/modules/teams.js +++ b/src/modules/teams.js @@ -1,7 +1,8 @@ import { dropdownMap } from '@/api/dropdowns'; const teams = { - service: 'ums', + service: 'detailing', + getOneService: 'ums', module: 'team', title: 'Teams', baseUrl: '/team', diff --git a/src/modules/tracks.js b/src/modules/tracks.js new file mode 100644 index 0000000..d824ea6 --- /dev/null +++ b/src/modules/tracks.js @@ -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; diff --git a/src/modules/users.js b/src/modules/users.js index 8c885d6..8dc1fc0 100644 --- a/src/modules/users.js +++ b/src/modules/users.js @@ -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; diff --git a/src/routes/index.jsx b/src/routes/index.jsx index 77e760a..07ffd04 100644 --- a/src/routes/index.jsx +++ b/src/routes/index.jsx @@ -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: }, - { path: `${config.baseUrl.replace(/^\//, '')}/addnew`, element: }, ]; + if (!config.skipAdd) { + routes.push({ path: `${config.baseUrl.replace(/^\//, '')}/addnew`, element: }); + } if (!config.skipEdit) { routes.push({ path: `${config.baseUrl.replace(/^\//, '')}/edit`, element: }); } @@ -150,6 +174,28 @@ export const routes = [ ...moduleRoutes(formexports), ...moduleRoutes(reminders), ...moduleRoutes(leaderboards), + { path: '/leaderboard/dashboard', element: }, + ...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: }, { path: '/metricreport/report', element: }, { path: '/coming-soon', element: }, diff --git a/src/views/content/MagMediaViewers.jsx b/src/views/content/MagMediaViewers.jsx index d4acd15..3b74707 100644 --- a/src/views/content/MagMediaViewers.jsx +++ b/src/views/content/MagMediaViewers.jsx @@ -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() { {pageRows.length === 0 ? ( - - - No viewers - - + ) : ( pageRows.map((user) => ( diff --git a/src/views/dashboard/EmptyChart.jsx b/src/views/dashboard/EmptyChart.jsx index e31a91b..ed9b89c 100644 --- a/src/views/dashboard/EmptyChart.jsx +++ b/src/views/dashboard/EmptyChart.jsx @@ -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 ( -
-
- -
-
- ); +export default function EmptyChart({ label = 'Not found' }) { + const description = !label || label === 'No data' ? 'No data to show' : label; + return ; } diff --git a/src/views/dashboard/MetricCard.jsx b/src/views/dashboard/MetricCard.jsx index b514d4f..503de60 100644 --- a/src/views/dashboard/MetricCard.jsx +++ b/src/views/dashboard/MetricCard.jsx @@ -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 ( @@ -104,19 +28,23 @@ export default function MetricCard({ metric }) {
{metric.label}

- {metric.value} - {metric.type === 'verses' ? ` / ${metric.max}` : ''} + {value} + {metric.type === 'verses' ? ` / ${metric.max ?? 0}` : ''}

- {(metric.available_charts || []).map((item) => ( - - {item.name} Chart - - ))} + {(metric.available_charts || []).map((item) => { + const chartName = typeChartList(item)[0] || item.chart; + if (!item.type || !chartName) return null; + return ( + + {item.name} Chart + + ); + })}
diff --git a/src/views/dashboard/helpers.js b/src/views/dashboard/helpers.js index 0ef5c07..f22804e 100644 --- a/src/views/dashboard/helpers.js +++ b/src/views/dashboard/helpers.js @@ -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; diff --git a/src/views/dashboard/index.jsx b/src/views/dashboard/index.jsx index 6211e83..b571806 100644 --- a/src/views/dashboard/index.jsx +++ b/src/views/dashboard/index.jsx @@ -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() { ) : ( <> + {metrics.length === 0 && widgets.length === 0 ? : null} {metrics.map((metric) => ( diff --git a/src/views/dashboard/widgets/MilestoneTeamLeaderboard.jsx b/src/views/dashboard/widgets/MilestoneTeamLeaderboard.jsx index 31dd2d1..4503ca7 100644 --- a/src/views/dashboard/widgets/MilestoneTeamLeaderboard.jsx +++ b/src/views/dashboard/widgets/MilestoneTeamLeaderboard.jsx @@ -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, }; diff --git a/src/views/dashboard/widgets/VivaCompatitionPointsTable.jsx b/src/views/dashboard/widgets/VivaCompatitionPointsTable.jsx index e24370f..7868210 100644 --- a/src/views/dashboard/widgets/VivaCompatitionPointsTable.jsx +++ b/src/views/dashboard/widgets/VivaCompatitionPointsTable.jsx @@ -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(); diff --git a/src/views/formaction/ConsoleList.jsx b/src/views/formaction/ConsoleList.jsx index bf7b08e..bf7aca5 100644 --- a/src/views/formaction/ConsoleList.jsx +++ b/src/views/formaction/ConsoleList.jsx @@ -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() { ) : ( - + {actions.length === 0 ? ( -

No quick actions available.

+ ) : ( actions.map((action, index) => ( - + setActive(action)} onKeyDown={(event) => event.key === 'Enter' && setActive(action)}> - {action.name} -

{action.name}

+
+ {action.name} +
+

{action.name}

diff --git a/src/views/leaderboard/Dashboard.jsx b/src/views/leaderboard/Dashboard.jsx new file mode 100644 index 0000000..0066ca1 --- /dev/null +++ b/src/views/leaderboard/Dashboard.jsx @@ -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 ( + + +
+ + Back to Dashboard + + {!error && users.length ? ( + + ) : null} +
+ {view?.periodLabel ?

{view.periodLabel}

: null} + + + {pills.length || periods.length ? ( +
+
+ {pills.map((pill) => ( + + {pill.label} + + ))} +
+ {periods.length ? ( +
+ + onPeriodChange(event.target.value)} + > + {periods.map((item) => ( + + ))} + +
+ ) : null} +
+ ) : null} + {loading ? ( +
+ +
+ ) : error ? ( +

{error}

+ ) : !users.length ? ( +

No leaderboard data found.

+ ) : ( +
+ + + + + + + + + {metrics.map((metric) => ( + + ))} + + + + {users.map((user) => ( + + + + + + + {metrics.map((metric) => ( + + ))} + + ))} + +
RankUserEmployee CodeDesignation / PositionScore{metric.name}
{user.rank} +
+ {user.avatar ? ( + + ) : null} + {user.name || '-'} +
+
{user.employee_code || '-'} + {user.designation || '-'} + {user.position_name ? ( + <> +
+ {user.position_name} + + ) : null} +
{user.score}{user.metric_values?.[metric.id] ?? '—'}
+
+ )} +
+
+
+ ); +} diff --git a/src/views/metricreport/List.jsx b/src/views/metricreport/List.jsx index f6d9b6e..2877b8c 100644 --- a/src/views/metricreport/List.jsx +++ b/src/views/metricreport/List.jsx @@ -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() { {pageRows.length === 0 ? ( - - - No records - - + ) : ( pageRows.map((item) => ( diff --git a/src/views/metricreport/Report.jsx b/src/views/metricreport/Report.jsx index 4e0ffc9..5297b78 100644 --- a/src/views/metricreport/Report.jsx +++ b/src/views/metricreport/Report.jsx @@ -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 ( -

Metric Report

{error ?
{error}
: null} {loading || !graph ? (
@@ -233,43 +247,41 @@ export default function MetricReport() {

- - {canExport(post, graph) ? ( - - ) : null} - + {canExport(post, graph) ? ( + + ) : null} + +
-
- {asArray(charts) - .filter((item) => asArray(item.charts).length > 0) - .map((item) => ( +
+ {typeTabs.map((item) => ( {item.name} @@ -282,7 +294,7 @@ export default function MetricReport() { Period
- onPeriodChange(event.target.value)}> + onPeriodChange(event.target.value)}> {asArray(periods).map((item) => ( - {(post.period || query.period) === 'custom-date' ? ( + {pickingCustomDate || (post.period || query.period) === 'custom-date' ? (
setEndDate(date ? toIsoDate(date) : '')} /> - +
) : null}
@@ -315,14 +327,24 @@ export default function MetricReport() { {currentCharts.length > 1 ? ( -
+
{currentCharts.map((chart) => ( - {titleCase(chart)} + {chartTabLabel(chart)} ))}
@@ -354,7 +376,7 @@ export default function MetricReport() { Filter out Metrics - {asArray(graph?.metrics).map((metric) => ( + {uniqueBy(graph?.metrics, (metric) => metric?.metric).map((metric) => ( ))} - - diff --git a/src/views/metricreport/ReportViews.jsx b/src/views/metricreport/ReportViews.jsx index fca31b8..59d862a 100644 --- a/src/views/metricreport/ReportViews.jsx +++ b/src/views/metricreport/ReportViews.jsx @@ -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 {value}; +} + function ReportTable({ children }) { return ( -
+
{children}
); } 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 (
{isTeam ? ( breadcrumbs.length === 0 ? ( -
-
- - - -
- - {Array.from({ length: Number(graph.level) || 0 }, (_, index) => Number(graph.level) - index).map((level) => ( - onLevel(level)}> - Level {level} - - ))} - +
+ Level + {Array.from({ length: maxLevel || 0 }, (_, index) => maxLevel - index).map((level) => ( + + ))}
) : ( -
    -
  1. -
  2. - {breadcrumbs.map((crumb) => ( -
  3. - -
  4. - ))} + {breadcrumbs.map((crumb, index) => { + const isCurrent = index === breadcrumbs.length - 1; + return ( +
  5. +
  6. + ); + })}
) ) : null} @@ -89,14 +104,17 @@ function LeaderboardTable({ graph, post, breadcrumbs, onLevel, onChilds, onReset Region Area Territory - {graph.type === 'verses' ? null : Active User} + Active User {metrics.map((metric) => ( {metric.display} ))} - {rows.map((row, index) => { + {rows.length === 0 ? ( + + ) : ( + 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 {showChilds ? ( - + ) : null} {showRank ? ( - + ) : null} @@ -120,18 +144,19 @@ function LeaderboardTable({ graph, post, breadcrumbs, onLevel, onChilds, onReset {row.user?.name || ''} {row.user?.employee_code || '-'} {row.user?.designation || '-'} - {positionValue(row.user, 'Zone')} + {positionValue(row.user, 'Zone', { countryFallback: true })} {positionValue(row.user, 'Region')} {positionValue(row.user, 'Area')} {positionValue(row.user, 'Territory')} - {graph.type === 'verses' ? null : {row.stats?.ucount ?? 0}} + {graph.type === 'verses' ? '-' : (row.stats?.ucount ?? 0)} {metrics.map((metric) => { const value = leaderboardStatValue(row.stats, metric); return {value !== null && value !== undefined ? value : '-'}; })} ); - })} + }) + )}
@@ -148,12 +173,16 @@ function SimpleTrendTable({ graph }) { - {asArray(graph.values).map((row, index) => ( + {asArray(graph.values).length === 0 ? ( + + ) : ( + asArray(graph.values).map((row, index) => ( {row.x} {row.y} - ))} + )) + )} ); @@ -172,14 +201,18 @@ function GroupedTrendTable({ graph }) { - {asArray(metrics[0]?.stats).map((stat, index) => ( + {asArray(metrics[0]?.stats).length === 0 ? ( + + ) : ( + asArray(metrics[0]?.stats).map((stat, index) => ( {stat.x} {metrics.map((metric) => ( {metric.stats?.[index]?.y ?? 0} ))} - ))} + )) + )} ); @@ -197,71 +230,79 @@ function VersesTrendTable({ graph, withPercent }) { - {asArray(graph.values?.[0]?.stats).map((row, index) => ( + {asArray(graph.values?.[0]?.stats).length === 0 ? ( + + ) : ( + asArray(graph.values?.[0]?.stats).map((row, index) => ( {row.x} {row.actual} {row.target} {withPercent ? {row.y}% : null} - ))} + )) + )} ); } 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 ( - - - User - Employee Code - {positions ? ( - <> - Zone - Region - Area - Territory - - ) : null} - Role - {metrics.map((metric) => ( - {metric.metric} - ))} - - - - {users.map((user) => ( - - - - - - {user.name || '-'} -
- {user.designation || '-'} - - {user.employee_code || ''} + + + User + Employee Code {positions ? ( <> - {positionValue(user, 'Zone')} - {positionValue(user, 'Region')} - {positionValue(user, 'Area')} - {positionValue(user, 'Territory')} + Zone + Region + Area + Territory ) : null} - {roleLabel(user.roles)} + Role {metrics.map((metric) => ( - {objectGet(metric.stats, user.id) ?? '-'} + {metric.metric || metric.display} ))} - ))} - -
+ + + {users.length === 0 ? ( + + ) : ( + users.map((user) => ( + + + + + + {user.name || '-'} +
+ {user.designation || '-'} + + {user.employee_code || ''} + {positions ? ( + <> + {positionValue(user, 'Zone')} + {positionValue(user, 'Region')} + {positionValue(user, 'Area')} + {positionValue(user, 'Territory')} + + ) : null} + {roleLabel(user.roles)} + {metrics.map((metric) => ( + {objectGet(metric.stats, user.id) ?? '-'} + ))} + + )) + )} + + ); } @@ -270,146 +311,155 @@ function ContactsTable({ graph, labels }) { const attrs = asArray(graph.contact_attributes); return ( - - - Doctor - MSL Code - {attrs.map((attr) => ( - {attributeLabel(attr, labels)} - ))} - User - Employee Code - Zone - Region - Area - Territory - {metrics.map((metric) => ( - {metric.metric} - ))} - - - - {asArray(graph.values?.contacts).map((contact) => { - const user = objectGet(graph.values?.users, contact.id); - return ( - - {contact.name} - {contact.msl_code} - {attrs.map((attr) => ( - {contact[attr] ?? ''} - ))} - {user ? ( - <> - - {user.name} -
- {user.designation} - - {user.employee_code || ''} - {positionValue(user, 'Zone')} - {positionValue(user, 'Region')} - {positionValue(user, 'Area')} - {positionValue(user, 'Territory')} - - ) : ( - <> - - - - - - - - - - - - - - )} - {metrics.map((metric) => ( - {objectGet(metric.stats, contact.id) ?? '-'} - ))} - - ); - })} - -
+ + + Doctor + MSL Code + {attrs.map((attr) => ( + {attributeLabel(attr, labels)} + ))} + User + Employee Code + Zone + Region + Area + Territory + {metrics.map((metric) => ( + {metric.metric || metric.display} + ))} + + + + {asArray(graph.values?.contacts).length === 0 ? ( + + ) : ( + asArray(graph.values?.contacts).map((contact) => { + const user = objectGet(graph.values?.users, contact.id); + return ( + + {contact.name} + {contact.msl_code} + {attrs.map((attr) => ( + {contact[attr] ?? ''} + ))} + {user ? ( + <> + + {user.name} +
+ {user.designation} + + {user.employee_code || ''} + {positionValue(user, 'Zone')} + {positionValue(user, 'Region')} + {positionValue(user, 'Area')} + {positionValue(user, 'Territory')} + + ) : ( + <> + - + - + - + - + - + - + + )} + {metrics.map((metric) => ( + {objectGet(metric.stats, contact.id) ?? '-'} + ))} + + ); + }) + )} + + ); } 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 ( - - - - User - - Employee Code - {positions ? ( - <> - Zone - Region - Area - Territory - - ) : null} - Role - {metrics.map((metric) => ( - - {metric.metric} + + + + User - ))} - - - {metrics.map((metric) => - dates.map((date) => ( - {formatIncrementalHeader(date)} - )), - )} - - - - {users.map((user) => ( - - - - - - {user.name} -
- {user.designation} - - {user.employee_code || ''} + Employee Code {positions ? ( <> - {positionValue(user, 'Zone')} - {positionValue(user, 'Region')} - {positionValue(user, 'Area')} - {positionValue(user, 'Territory')} + Zone + Region + Area + Territory ) : null} - {roleLabel(user.roles)} + Role + {metrics.map((metric) => ( + + {metric.metric || metric.display} + + ))} + + {metrics.map((metric) => dates.map((date) => ( - {incrementalStat(metric, user.id, date)} + {formatIncrementalHeader(date)} )), )} - ))} - -
+ + + {users.length === 0 ? ( + + ) : ( + users.map((user) => ( + + + + + + {user.name} +
+ {user.designation} + + {user.employee_code || ''} + {positions ? ( + <> + {positionValue(user, 'Zone')} + {positionValue(user, 'Region')} + {positionValue(user, 'Area')} + {positionValue(user, 'Territory')} + + ) : null} + {roleLabel(user.roles)} + {metrics.map((metric) => + dates.map((date) => ( + {incrementalStat(metric, user.id, date)} + )), + )} + + )) + )} + + ); } function ApexView({ graph, post }) { const chart = buildApexChart(graph, post); const summary = showValueSummary(post, graph); + const isValueTab = post?.type === 'value'; return (
- {chart ? ( + {isValueTab ? null : chart && !chart.empty ? ( ) : ( - + )} {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 ( - ) : ( - ); } if (graph.type === 'simple' && type === 'trend' && chart === 'tabular') { - return hasRows ? : ; + return hasTrendRows ? : ; } if (graph.type === 'grouped' && type === 'trend' && chart === 'tabular') { - return hasRows ? : ; + return hasTrendRows ? : ; } - if (type === 'details' && chart === 'tabular') { - return graph.values?.metrics && hasRows ? : ; + if (isDetailsTabular) { + return graph.values?.metrics ? : ; } - if (type === 'contacts' && chart === 'contacts') { - return graph.values?.metrics && hasRows ? : ; + if (isContactsView) { + return graph.values?.metrics ? : ; } if (type === 'trend' && graph.type === 'verses' && chart === 'tabular') { - return asArray(graph.values?.[0]?.stats).length ? : ; + return asArray(graph.values?.[0]?.stats).length ? : ; } - if (type === 'incremental' && chart === 'incremental') { - return graph.values?.metrics && hasRows ? : ; + if (isIncrementalView) { + return graph.values?.metrics ? : ; } if (isTableChart(post, graph)) { - return ; + return ; } return ; } diff --git a/src/views/metricreport/buildChart.js b/src/views/metricreport/buildChart.js index 075c61f..b55efe8 100644 --- a/src/views/metricreport/buildChart.js +++ b/src/views/metricreport/buildChart.js @@ -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; diff --git a/src/views/metricreport/exportReport.js b/src/views/metricreport/exportReport.js index 0f469f2..e9cca7b 100644 --- a/src/views/metricreport/exportReport.js +++ b/src/views/metricreport/exportReport.js @@ -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) : []; diff --git a/src/views/metricreport/helpers.js b/src/views/metricreport/helpers.js index afafe3e..46b5f30 100644 --- a/src/views/metricreport/helpers.js +++ b/src/views/metricreport/helpers.js @@ -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; } diff --git a/src/views/modules/EntityFormPage.jsx b/src/views/modules/EntityFormPage.jsx index 5edda74..ccc3798 100644 --- a/src/views/modules/EntityFormPage.jsx +++ b/src/views/modules/EntityFormPage.jsx @@ -1,5 +1,5 @@ import EntityForm from '@/components/EntityForm'; export default function EntityFormPage({ module }) { - return ; + return ; } diff --git a/src/views/modules/EntityListPage.jsx b/src/views/modules/EntityListPage.jsx index f57dc81..943cbd7 100644 --- a/src/views/modules/EntityListPage.jsx +++ b/src/views/modules/EntityListPage.jsx @@ -1,5 +1,5 @@ import EntityList from '@/components/EntityList'; export default function EntityListPage({ module }) { - return ; + return ; } diff --git a/src/views/organisation/HierarchyTree.jsx b/src/views/organisation/HierarchyTree.jsx index 351ace1..d0bf057 100644 --- a/src/views/organisation/HierarchyTree.jsx +++ b/src/views/organisation/HierarchyTree.jsx @@ -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 ( +
    + {childIds.map((id) => { + const node = nodesById[id]; + if (!node) return null; + return ( +
  • + +
  • + ); + })} +
+ ); +}); + +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 ( + <> +
+
+ {canExpand ? ( + + ) : null} +
+ {node.name} ({node.type}) — {ownerLabel(node)} +
+
+
+ + + + +
+
+ {keepKids.current ? ( + + ) : null} + + ); +}); + export default function HierarchyTree() { const [nodesById, setNodesById] = useState({}); const [childrenById, setChildrenById] = useState({}); - const [expanded, setExpanded] = useState({}); const [loadingIds, setLoadingIds] = useState({}); const [rootId, setRootId] = useState(''); const [positions, setPositions] = useState({}); @@ -69,6 +204,7 @@ export default function HierarchyTree() { const [roles, setRoles] = useState({}); const [channels, setChannels] = useState({}); const [loading, setLoading] = useState(true); + const [loadingAll, setLoadingAll] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); const [message, setMessage] = useState(''); @@ -80,6 +216,9 @@ export default function HierarchyTree() { const [moveForm, setMoveForm] = useState({ id: '', parent_id: '', previous_owner: '', code: '' }); const lookupsLoaded = useRef(false); const lookupsInflight = useRef(null); + const expandStore = useMemo(() => createExpandStore(), []); + const childrenByIdRef = useRef(childrenById); + childrenByIdRef.current = childrenById; const isCrestroad = () => getSession()?.domain === 'crestroad'; @@ -162,25 +301,25 @@ export default function HierarchyTree() { setNodesById(ingested.nodes); setChildrenById(ingested.children); setRootId(ingested.rootId); - setExpanded(ingested.rootId ? { [ingested.rootId]: true } : {}); + expandStore.replace(ingested.rootId ? { [ingested.rootId]: true } : {}); return; } if (!me?.id) { setNodesById({}); setChildrenById({}); setRootId(''); - setExpanded({}); + expandStore.replace({}); return; } setRootId(me.id); - setExpanded({ [me.id]: true }); + expandStore.replace({ [me.id]: true }); mergeChildren(me.id, items, [{ ...me, has_children: items.length > 0 }]); } catch (err) { setError(err.message || 'Unable to load hierarchy'); } finally { setLoading(false); } - }, [mergeChildren]); + }, [mergeChildren, expandStore]); useEffect(() => { loadTree(); @@ -226,30 +365,29 @@ export default function HierarchyTree() { [fetchChildren, loadTree], ); - const toggleExpand = async (id) => { - if (expanded[id]) { - setExpanded((prev) => { - const next = { ...prev }; - delete next[id]; - return next; - }); - return; - } - if (!isCrestroad() && childrenById[id] === undefined) { - await fetchChildren(id); - } - setExpanded((prev) => ({ ...prev, [id]: true })); - }; + const toggleExpand = useCallback( + async (id) => { + if (expandStore.isOpen(id)) { + expandStore.collapse(id); + return; + } + if (!isCrestroad() && childrenByIdRef.current[id] === undefined) { + await fetchChildren(id); + } + expandStore.expand(id); + }, + [expandStore, fetchChildren], + ); - const openAdd = (node) => { + const openAdd = useCallback((node) => { setChildTitle(`Add New Child for ${node.name}`); setLockCode(false); setChildForm({ ...emptyChild, parent_id: node.id }); setChildOpen(true); loadLookups(); - }; + }, [loadLookups]); - const openEdit = (node) => { + const openEdit = useCallback((node) => { setChildTitle(`Edit ${node.name}`); setLockCode(true); setChildForm({ @@ -262,9 +400,9 @@ export default function HierarchyTree() { }); setChildOpen(true); loadLookups(); - }; + }, [loadLookups]); - const openMove = (node) => { + const openMove = useCallback((node) => { setMoveForm({ id: node.id, parent_id: node.parent_id, @@ -273,9 +411,9 @@ export default function HierarchyTree() { }); setMoveOpen(true); loadLookups(); - }; + }, [loadLookups]); - const onVacate = async (node) => { + const onVacate = useCallback(async (node) => { if (!window.confirm(`Vacate "${node.name}"? This moves the position to vacant (not a hard delete).`)) return; setSaving(true); setError(''); @@ -296,7 +434,7 @@ export default function HierarchyTree() { } finally { setSaving(false); } - }; + }, [refetchParents, rootId]); const saveChild = async (event) => { event.preventDefault(); @@ -318,7 +456,7 @@ export default function HierarchyTree() { setChildOpen(false); setMessage('Successfully Saved !!!'); if (childForm.parent_id) { - setExpanded((prev) => ({ ...prev, [childForm.parent_id]: true })); + expandStore.expand(childForm.parent_id); } await refetchParents([childForm.parent_id]); } catch (err) { @@ -415,6 +553,42 @@ export default function HierarchyTree() { } }; + const onLoadAll = async () => { + setLoadingAll(true); + setError(''); + setMessage(''); + try { + if (isCrestroad()) { + expandStore.replace(collectExpanded(nodesById, childrenById, rootId)); + setMessage('Hierarchy loaded'); + return; + } + const response = await signedGet('ums', 'hierarchy/get_all', {}, { timeout: 120000 }); + if (response?.statusCode === 401 || response?.statusCode === 403) { + setError('Unauthorised'); + return; + } + if (!isSuccess(response)) { + setError(response?.message || 'Unable to load hierarchy'); + return; + } + const ingested = ingestFullTree(response.data?.items || [], response.data?.me); + if (!ingested.rootId) { + setError('Unable to load hierarchy'); + return; + } + setNodesById(ingested.nodes); + setChildrenById(ingested.children); + setRootId(ingested.rootId); + expandStore.replace(collectExpanded(ingested.nodes, ingested.children, ingested.rootId)); + setMessage('Hierarchy loaded'); + } catch (err) { + setError(err.message || 'Unable to load hierarchy'); + } finally { + setLoadingAll(false); + } + }; + const onRepopulate = async () => { if (!window.confirm('Repopulate typed position data for this domain?')) return; setSaving(true); @@ -426,7 +600,7 @@ export default function HierarchyTree() { setMessage('Successfully Populated !!!'); setNodesById({}); setChildrenById({}); - setExpanded({}); + expandStore.replace({}); await loadTree(); } else { setError(response?.message || 'Repopulate failed'); @@ -438,75 +612,14 @@ export default function HierarchyTree() { } }; - const NodeList = ({ parentId }) => { - const childIds = childrenById[parentId]; - if (!childIds || !childIds.length) return null; - return ( -
    - {childIds.map((id) => { - const node = nodesById[id]; - if (!node) return null; - return ( -
  • - -
  • - ); - })} -
- ); - }; - - 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 ( - <> -
-
- {canExpand ? ( - - ) : ( - - )} -
- {node.name} ({node.type}) — {ownerLabel(node)} -
-
-
- - - - -
-
- {isExpanded ? : null} - - ); - }; + const actions = useMemo( + () => ({ toggleExpand, openAdd, openMove, openEdit, onVacate }), + [toggleExpand, openAdd, openMove, openEdit, onVacate], + ); + const treeValue = useMemo( + () => ({ nodesById, childrenById, loadingIds, expandStore, actions }), + [nodesById, childrenById, loadingIds, expandStore, actions], + ); const rootNode = rootId ? nodesById[rootId] : null; @@ -515,10 +628,13 @@ export default function HierarchyTree() {
- + - @@ -537,7 +653,9 @@ export default function HierarchyTree() { ) : !rootNode ? (
No hierarchy nodes
) : ( - + + + )} diff --git a/src/views/organisation/TeamPlayers.jsx b/src/views/organisation/TeamPlayers.jsx index 9bd54c0..3cd8abe 100644 --- a/src/views/organisation/TeamPlayers.jsx +++ b/src/views/organisation/TeamPlayers.jsx @@ -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() {
) : (grouped.root || []).length === 0 ? ( -
No players
+ ) : ( )}