Added Metric list view, Metric details, Campaigns, Contact Approval/Reject Notification flow.

This commit is contained in:
Amey Bhagwat 2026-05-29 17:50:18 +05:30
parent 4b8f637794
commit faaefaeb3b
71 changed files with 7226 additions and 803 deletions

View File

@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-04-01T04:18:56.074962Z">
<DropdownSelection timestamp="2026-05-10T07:34:40.302331Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/amey/.android/avd/Pixel_9_Pro.avd" />
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/amey/.android/avd/Pixel_8.avd" />
</handle>
</Target>
</DropdownSelection>

View File

@ -1,4 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">

View File

@ -7,8 +7,8 @@ android {
applicationId "com.stratawiz.mainrapport"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 19
versionName "2.7"
versionCode 21
versionName "2.9"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@ -13,9 +13,11 @@ dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-device')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-geolocation')
implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-share')
implementation project(':capacitor-splash-screen')
implementation project(':capacitor-status-bar')
implementation project(':capawesome-capacitor-android-edge-to-edge-support')

View File

@ -0,0 +1,84 @@
package com.stratawiz.mainrapport;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.MediaStore;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
/**
* Saves CSV exports into the public Downloads collection via MediaStore.
*/
@CapacitorPlugin(name = "ExportSave")
public class ExportSavePlugin extends Plugin {
@PluginMethod
public void saveTextToDownloads(PluginCall call) {
String filename = call.getString("filename");
String content = call.getString("content");
String mimeType = call.getString("mimeType", "text/csv");
if (filename == null || content == null) {
call.reject("filename and content are required");
return;
}
byte[] data = content.getBytes(StandardCharsets.UTF_8);
saveBytesToDownloads(call, filename, mimeType, data);
}
private void saveBytesToDownloads(
PluginCall call,
String filename,
String mimeType,
byte[] data
) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
call.reject("Requires Android 10 (API 29) or newer");
return;
}
ContentResolver resolver = getContext().getContentResolver();
ContentValues values = new ContentValues();
values.put(MediaStore.MediaColumns.DISPLAY_NAME, filename);
values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
values.put(MediaStore.MediaColumns.IS_PENDING, 1);
Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
if (uri == null) {
call.reject("Could not create entry in Downloads");
return;
}
try (OutputStream out = resolver.openOutputStream(uri)) {
if (out == null) {
resolver.delete(uri, null, null);
call.reject("Could not open Downloads output stream");
return;
}
out.write(data);
} catch (Exception e) {
try {
resolver.delete(uri, null, null);
} catch (Exception ignored) {
}
call.reject("Write failed: " + e.getMessage(), e);
return;
}
ContentValues done = new ContentValues();
done.put(MediaStore.MediaColumns.IS_PENDING, 0);
resolver.update(uri, done, null, null);
JSObject ret = new JSObject();
ret.put("uri", uri.toString());
call.resolve(ret);
}
}

View File

@ -1,5 +1,13 @@
package com.stratawiz.mainrapport;
import android.os.Bundle;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(ExportSavePlugin.class);
super.onCreate(savedInstanceState);
}
}

View File

@ -14,6 +14,9 @@ project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-device'
project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/device/android')
include ':capacitor-filesystem'
project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android')
include ':capacitor-geolocation'
project(':capacitor-geolocation').projectDir = new File('../node_modules/@capacitor/geolocation/android')
@ -23,6 +26,9 @@ project(':capacitor-preferences').projectDir = new File('../node_modules/@capaci
include ':capacitor-push-notifications'
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
include ':capacitor-share'
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
include ':capacitor-splash-screen'
project(':capacitor-splash-screen').projectDir = new File('../node_modules/@capacitor/splash-screen/android')

View File

@ -1,5 +1,5 @@
{
"appId": "com.stratawiz.rapportapp.Rapport",
"appId": "com.stratawiz.mainrapport",
"appName": "Rapport 2.0",
"webDir": "dist",

View File

@ -359,14 +359,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = 63XMJEQMD4;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Rapport 2.0";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 4.6;
MARKETING_VERSION = 4.8;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.stratawiz.rapportapp.Rapport;
PRODUCT_NAME = "$(TARGET_NAME)";
@ -383,14 +383,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 6;
DEVELOPMENT_TEAM = 63XMJEQMD4;
INFOPLIST_FILE = App/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Rapport 2.0";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.business";
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 4.6;
MARKETING_VERSION = 4.8;
PRODUCT_BUNDLE_IDENTIFIER = com.stratawiz.rapportapp.Rapport;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";

View File

@ -15,9 +15,11 @@ def capacitor_pods
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorBrowser', :path => '../../node_modules/@capacitor/browser'
pod 'CapacitorDevice', :path => '../../node_modules/@capacitor/device'
pod 'CapacitorFilesystem', :path => '../../node_modules/@capacitor/filesystem'
pod 'CapacitorGeolocation', :path => '../../node_modules/@capacitor/geolocation'
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
pod 'CapacitorSplashScreen', :path => '../../node_modules/@capacitor/splash-screen'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'CapgoCapacitorNavigationBar', :path => '../../node_modules/@capgo/capacitor-navigation-bar'

View File

@ -1,29 +1,34 @@
PODS:
- Capacitor (7.4.3):
- Capacitor (7.6.2):
- CapacitorCordova
- CapacitorApp (7.0.2):
- CapacitorApp (7.1.2):
- Capacitor
- CapacitorBrowser (7.0.2):
- CapacitorBrowser (7.0.5):
- Capacitor
- CapacitorCommunityDeviceSecurityDetect (7.0.0):
- Capacitor
- CapacitorCordova (7.4.3)
- CapacitorDevice (7.0.2):
- CapacitorCordova (7.6.2)
- CapacitorDevice (7.0.5):
- Capacitor
- CapacitorGeolocation (7.1.7):
- CapacitorFilesystem (7.1.8):
- Capacitor
- IONFilesystemLib (~> 1.1.1)
- CapacitorGeolocation (7.1.8):
- Capacitor
- IONGeolocationLib (= 1.0.1)
- CapacitorPreferences (7.0.2):
- CapacitorPreferences (7.0.4):
- Capacitor
- CapacitorPushNotifications (7.0.2):
- CapacitorPushNotifications (7.0.6):
- Capacitor
- CapacitorSplashScreen (7.0.2):
- CapacitorShare (7.0.4):
- Capacitor
- CapacitorStatusBar (7.0.2):
- CapacitorSplashScreen (7.0.5):
- Capacitor
- CapacitorStatusBar (7.0.6):
- Capacitor
- CapacitorVoiceRecorder (7.0.6):
- Capacitor
- CapgoCapacitorNavigationBar (7.1.27):
- CapgoCapacitorNavigationBar (7.3.18):
- Capacitor
- FirebaseCore (10.23.0):
- FirebaseCoreInternal (~> 10.0)
@ -74,13 +79,14 @@ PODS:
- GoogleUtilities/UserDefaults (7.13.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- IONFilesystemLib (1.1.2)
- IONGeolocationLib (1.0.1)
- nanopb (2.30910.0):
- nanopb/decode (= 2.30910.0)
- nanopb/encode (= 2.30910.0)
- nanopb/decode (2.30910.0)
- nanopb/encode (2.30910.0)
- OttimisCapacitorVolumes (6.0.0):
- OttimisCapacitorVolumes (6.1.0):
- Capacitor
- PromisesObjC (2.4.0)
@ -91,9 +97,11 @@ DEPENDENCIES:
- "CapacitorCommunityDeviceSecurityDetect (from `../../node_modules/@capacitor-community/device-security-detect`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorDevice (from `../../node_modules/@capacitor/device`)"
- "CapacitorFilesystem (from `../../node_modules/@capacitor/filesystem`)"
- "CapacitorGeolocation (from `../../node_modules/@capacitor/geolocation`)"
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
- "CapacitorShare (from `../../node_modules/@capacitor/share`)"
- "CapacitorSplashScreen (from `../../node_modules/@capacitor/splash-screen`)"
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
- CapacitorVoiceRecorder (from `../../node_modules/capacitor-voice-recorder`)
@ -109,6 +117,7 @@ SPEC REPOS:
- FirebaseMessaging
- GoogleDataTransport
- GoogleUtilities
- IONFilesystemLib
- IONGeolocationLib
- nanopb
- PromisesObjC
@ -126,12 +135,16 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/ios"
CapacitorDevice:
:path: "../../node_modules/@capacitor/device"
CapacitorFilesystem:
:path: "../../node_modules/@capacitor/filesystem"
CapacitorGeolocation:
:path: "../../node_modules/@capacitor/geolocation"
CapacitorPreferences:
:path: "../../node_modules/@capacitor/preferences"
CapacitorPushNotifications:
:path: "../../node_modules/@capacitor/push-notifications"
CapacitorShare:
:path: "../../node_modules/@capacitor/share"
CapacitorSplashScreen:
:path: "../../node_modules/@capacitor/splash-screen"
CapacitorStatusBar:
@ -144,30 +157,33 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@ottimis/capacitor-volumes"
SPEC CHECKSUMS:
Capacitor: b4741ca7affb32c1b70debd03df92cbf522d8a80
CapacitorApp: efc1e9748a8ea5d3e789cd82a4f451341cc7ac93
CapacitorBrowser: 45c681e7abc3b4384e54eb52bafeef1347d98fa8
Capacitor: 6a4db05694acab0f1ea4ecb5ae06e520d101c4f1
CapacitorApp: 6b89831465d6bc99bc89779b1b7f5327e33011ce
CapacitorBrowser: 3bcddd9dd07cfff9105dcc88ad64853be9862e50
CapacitorCommunityDeviceSecurityDetect: 6ae8377599edc16e2eaffa4f8e533822abd5dd11
CapacitorCordova: 435121e81a2df4d0034f0fb11fcefab5104cfdb5
CapacitorDevice: a50a45f0d075e55e2392c7a4be5404d4f69515de
CapacitorGeolocation: bcd9412a99fd542b585561238b210bd2b162b8f6
CapacitorPreferences: b583b5deb74e564c35968189d25d32c0fd41200d
CapacitorPushNotifications: 6ffcf5df95f17d71a1b02893549bc4727f80540c
CapacitorSplashScreen: 8d6c8cb0542a8e81585c593815db8785ed8ce454
CapacitorStatusBar: 5526833b87fa9c96a2a90c1322bb1f663c04505f
CapacitorCordova: 8214697bed435f6e7c5e3ed86d1632a8c42f7d77
CapacitorDevice: 12b136398a320a395ddc8d227ab532848058d796
CapacitorFilesystem: f54cd6b76be06fa7ceb219cf313d32e0d626ea87
CapacitorGeolocation: de4b0ccf90140528184e31676dd5e3a328eb589a
CapacitorPreferences: d82a7e3b95fcab43a553268b803356522910d153
CapacitorPushNotifications: c6158ba6f3777f281a675aa43e4011e9723e822b
CapacitorShare: 14b5d7f0a2c6de86dcad8995da80fb707ee32f62
CapacitorSplashScreen: b3d12b4324d93ce605fe57f05a199ade7f26189e
CapacitorStatusBar: a3acb591ff56301e28aa1a01791ba74a8e1faabb
CapacitorVoiceRecorder: b136a5989c9a306d57f0c9fc530be79a06ccf7b2
CapgoCapacitorNavigationBar: 540361dcdf457cde63bfbfddf88ac6d3f98613ac
CapgoCapacitorNavigationBar: ed31f8444dd1aa3587460d5231033895b4ea53be
FirebaseCore: 63efb128decaebb04c4033ed4e05fb0bb1cccef1
FirebaseCoreInternal: 6a292e6f0bece1243a737e81556e56e5e19282e3
FirebaseInstallations: 42d6ead4605d6eafb3b6683674e80e18eb6f2c35
FirebaseMessaging: 1b2270e66c81bbf184f70184db1d6a736ad0def5
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
GoogleUtilities: d053d902a8edaa9904e1bd00c37535385b8ed152
IONFilesystemLib: 21a63377696b2d8fab5632ecfb7d2ac67bddb68a
IONGeolocationLib: 20f9d0248a0b5264511fb57a37e25dd2badf797a
nanopb: 438bc412db1928dac798aa6fd75726007be04262
OttimisCapacitorVolumes: 0ef88225f9cf44244c308cfa592fa8dc21e30f75
OttimisCapacitorVolumes: d30cdc47144a3fc391a802cdb601a925de471eae
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PODFILE CHECKSUM: 0dd6b24f9b54ec75dfbf0f0129476763971a2872
PODFILE CHECKSUM: 8cc47c7b98e3d4ca0203322372619e418bc989ad
COCOAPODS: 1.15.2

129
package-lock.json generated
View File

@ -14,25 +14,30 @@
"@capacitor/browser": "^7.0.2",
"@capacitor/core": "^7.4.3",
"@capacitor/device": "^7.0.2",
"@capacitor/filesystem": "^7.1.8",
"@capacitor/geolocation": "^7.1.7",
"@capacitor/ios": "^7.4.3",
"@capacitor/preferences": "^7.0.2",
"@capacitor/push-notifications": "^7.0.2",
"@capacitor/share": "^7.0.4",
"@capacitor/splash-screen": "^7.0.2",
"@capacitor/status-bar": "^7.0.2",
"@capawesome/capacitor-android-edge-to-edge-support": "^7.2.3",
"@capgo/capacitor-navigation-bar": "^7.1.27",
"@ottimis/capacitor-volumes": "^6.0.0",
"@tanstack/react-query": "^5.85.5",
"@tanstack/react-table": "^8.21.3",
"agora-rtc-react": "^2.2.0",
"agora-rtc-sdk-ng": "^4.21.0",
"axios": "^1.6.7",
"bootstrap": "^5.3.2",
"capacitor-voice-recorder": "^7.0.6",
"dompurify": "^3.4.2",
"file-saver": "^2.0.5",
"filesize": "^10.1.0",
"formik": "^2.4.5",
"html-react-parser": "^6.0.1",
"html2canvas": "^1.4.1",
"lodash": "^4.17.21",
"pdfjs-dist": "^5.4.54",
"react": "^18.2.0",
@ -523,6 +528,18 @@
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@capacitor/filesystem": {
"version": "7.1.8",
"resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-7.1.8.tgz",
"integrity": "sha512-Qpw/2SE4/CzqAUvGgSM9hw/uXQ5qoOaF4wxbToXwpAaKPS+tzletS1h5ti3jjLmGcqizTs2sEXMtcsARW/Ceew==",
"license": "MIT",
"dependencies": {
"@capacitor/synapse": "^1.0.3"
},
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@capacitor/geolocation": {
"version": "7.1.8",
"resolved": "https://registry.npmjs.org/@capacitor/geolocation/-/geolocation-7.1.8.tgz",
@ -562,6 +579,15 @@
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@capacitor/share": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/share/-/share-7.0.4.tgz",
"integrity": "sha512-wNXxmXUcChrtbZ5jQv8scFIbIGKo3rk6B7qpWySxfUhYJP5NANgAqQ9Z69m0k/zYsucdh66SBU7XW1ykd9aUug==",
"license": "MIT",
"peerDependencies": {
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/@capacitor/splash-screen": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/@capacitor/splash-screen/-/splash-screen-7.0.5.tgz",
@ -2349,6 +2375,39 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -2459,7 +2518,7 @@
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true,
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
@ -2936,6 +2995,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -3401,19 +3469,6 @@
"@capacitor/core": ">=7.0.0"
}
},
"node_modules/capacitor-voice-recorder/node_modules/@capacitor/filesystem": {
"version": "8.1.2",
"resolved": "https://registry.npmjs.org/@capacitor/filesystem/-/filesystem-8.1.2.tgz",
"integrity": "sha512-doaaMfGoFR2hWU6aV6u83I+5ZsGyJVq+Gz4r9lMpJzUKMm1eMu0hLnFdV1aXZlU9FlK/RndFrVD8oRZfNOqWgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@capacitor/synapse": "^1.0.4"
},
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/capacitor-voice-recorder/node_modules/capacitor-blob-writer": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/capacitor-blob-writer/-/capacitor-blob-writer-1.1.20.tgz",
@ -3670,6 +3725,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@ -4656,6 +4720,12 @@
"node": "^10.12.0 || >=12.0.0"
}
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/filesize": {
"version": "10.1.6",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz",
@ -5368,6 +5438,19 @@
}
}
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"license": "MIT",
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/htmlparser2": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
@ -8664,6 +8747,15 @@
"node": ">=18"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"license": "MIT",
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@ -9055,6 +9147,15 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"license": "MIT",
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",

View File

@ -16,25 +16,30 @@
"@capacitor/browser": "^7.0.2",
"@capacitor/core": "^7.4.3",
"@capacitor/device": "^7.0.2",
"@capacitor/filesystem": "^7.1.8",
"@capacitor/geolocation": "^7.1.7",
"@capacitor/ios": "^7.4.3",
"@capacitor/preferences": "^7.0.2",
"@capacitor/push-notifications": "^7.0.2",
"@capacitor/share": "^7.0.4",
"@capacitor/splash-screen": "^7.0.2",
"@capacitor/status-bar": "^7.0.2",
"@capawesome/capacitor-android-edge-to-edge-support": "^7.2.3",
"@capgo/capacitor-navigation-bar": "^7.1.27",
"@ottimis/capacitor-volumes": "^6.0.0",
"@tanstack/react-query": "^5.85.5",
"@tanstack/react-table": "^8.21.3",
"agora-rtc-react": "^2.2.0",
"agora-rtc-sdk-ng": "^4.21.0",
"axios": "^1.6.7",
"bootstrap": "^5.3.2",
"capacitor-voice-recorder": "^7.0.6",
"dompurify": "^3.4.2",
"file-saver": "^2.0.5",
"filesize": "^10.1.0",
"formik": "^2.4.5",
"html-react-parser": "^6.0.1",
"html2canvas": "^1.4.1",
"lodash": "^4.17.21",
"pdfjs-dist": "^5.4.54",
"react": "^18.2.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from "react";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
//Utility
import { handleThemeChange, handleAlert } from "./util/util";
@ -20,7 +20,9 @@ import Rewards from "./pages/Rewards";
import Appreciations from "./pages/Appreciations/Appreciations";
import Profile from "./pages/Profile";
import MetricReports from "./pages/MetricReports";
import PerformanceStatsList from "./pages/PerformanceStatsList";
import CampaignDetails from "./pages/CampaignDetails";
import CampaignLeaderboard from "./pages/CampaignLeaderboard";
import QuickActionsContent from "./pages/QuickActionsContent";
import Magazines from "./pages/Magazines";
import Magazine from "./components/Magazines/Magazine";
@ -28,6 +30,7 @@ import MagazineDetails from "./pages/MagazineDetails";
import MessageDetails from "./pages/WallMessages/MessageDetails";
import Messages from "./pages/WallMessages/Messages";
import ApproveRejectFormSubmission from "./pages/ApproveRejectFormSubmission";
import ApproveRejectContactSubmission from "./pages/ApproveRejectContactSubmission";
import Contests from "./pages/Contests";
import ContestDetails from "./pages/ContestDetails";
import UploadResponse from "./pages/UploadResponse";
@ -351,7 +354,13 @@ function App() {
/>
<AppContext.Provider
value={{ userProfile, notificationStats, getNotificationStats, backLink, setBackLink }}
value={{
userProfile,
notificationStats,
getNotificationStats,
backLink,
setBackLink,
}}
>
<ThemeContext.Provider value={{ theme, setTheme }}>
<BrowserRouter>
@ -488,9 +497,15 @@ function App() {
/>
}
>
<Route index element={<MetricReports />} />
<Route index element={<PerformanceStatsList />} />
<Route path=":metricViewId" element={<MetricReports />} />
</Route>
<Route
path="/performance-stats"
element={<Navigate to="/metric-reports" replace />}
/>
<Route
path="/campaigns"
element={
@ -501,7 +516,8 @@ function App() {
/>
}
>
<Route index element={<CampaignDetails />} />
<Route path=":id/leaderboard" element={<CampaignLeaderboard />} />
<Route path=":id" element={<CampaignDetails />} />
</Route>
<Route
@ -521,6 +537,23 @@ function App() {
<Route index element={<ApproveRejectFormSubmission />} />
</Route>
<Route
path="/approve-reject-contact-submission"
element={
<AuthGuard
auth={isLogin}
component={DashboardLayout}
page="approve-reject-contact-submission"
/>
}
>
<Route
path=":entityId"
element={<ApproveRejectContactSubmission />}
/>
<Route index element={<ApproveRejectContactSubmission />} />
</Route>
<Route
path="/magazines"
element={

View File

@ -0,0 +1,152 @@
import React from "react";
import { Card, Row, Col, Button } from "react-bootstrap";
import {
CAMPAIGN_RANK_BLOCKS,
getCampaignCardGradientStyle,
getCampaignRankBlockAppearance,
getCampaignRankIconStyle,
getCampaignRankIconTag,
readCampaignField,
readCampaignRankDisplay,
} from "../../util/campaignHelpers";
import CustomImage from "../UI/CustomImage/CustomImage";
import SectionHeading from "../UI/SectionHeading/SectionHeading";
import CustomIcon from "../CustomIcon/CustomIcon";
import "./DashboardCampaignsCard.css";
function getCampaignImage(campaign) {
const icon = campaign?.icon ?? campaign?.image;
if (icon != null && String(icon).trim() !== "") return icon;
return "/img/campaign-logo2.png";
}
/**
* Shared campaign header + rank tiles (dashboard swiper and campaign details).
*/
function CampaignSummaryCard({
campaign,
onHeaderClick,
onRankClick,
showNavigateButton = false,
className = "",
}) {
const campaignImage = getCampaignImage(campaign);
const campaignName = campaign?.name || campaign?.title || "Campaign";
const cardBackgroundStyle = getCampaignCardGradientStyle(campaign);
const totalPoints = readCampaignField(
campaign,
["total_points", "totalPoints", "overall_points", "overallPoints", "points"],
"-"
);
const campaignMessage = campaign?.message && campaign?.message !== "" ? campaign?.message : "";
return (
<Card
className={`card-theme dashboard-campaigns-card border-0 shadow-sm p- rounded-4 ${className}`.trim()}
// style={cardBackgroundStyle}
style={{background: "linear-gradient(153deg, rgb(34 92 243 / 84%) 0%, rgb(52 30 112 / 87%) 100%)"}}
>
<Card.Header
className="pt- px-1 pb-3 position-relative w-100"
onClick={onHeaderClick}
role={onHeaderClick ? "button" : undefined}
style={onHeaderClick ? { cursor: "pointer" } : undefined}
>
<div className="d-flex align-items-center">
<div className="flex-shrink-0 campaign-icon-wrapper rounded-circl rounded-3 overflow-hidden" style={{ width: 90, height: 90, outline: "8px solid rgba(255,255,255,0.12)" }}>
<CustomImage
src={campaignImage}
alt={campaignName}
wrapperClassName="card-img"
/>
</div>
<div className="ms-4 flex-grow-1">
<SectionHeading size="" className="font-17 fw-b mb-3 text-white line-clamp">
{campaignName}
</SectionHeading>
<div className="d-flex justify-content-start align-items-center">
<div className="me-2 pe-1">
<div style={{ width: 32, height: 32, background: "linear-gradient(140deg, rgb(102 152 234), rgb(40 111 198))" }} className="rounded-circle d-flex justify-content-center align-items-center">
<CustomIcon tag="star2" className="font-15 text-white" />
</div>
</div>
<SectionHeading size="sm" className="fw-n text-white">
<div className="fw-n font-11" style={{ color: "#bbd6ff" }}>Total Points</div>
<span className="fw-sb font-18 lh-1">{totalPoints}</span>
</SectionHeading>
{/* {showNavigateButton ? (
<Button
type="button"
className="btn-theme btn-s btn-lin btn-gray stretched-link rounded-circle fw-sb mt-0 position-absolute"
style={{
color: "#333",
textDecoration: "none",
background: "rgba(255,255,255,0.5)",
backdropFilter: "blur(5px)",
top: '-2px',
right: '-2px'
}}
tabIndex={-1}
aria-hidden
>
<CustomIcon tag="arrow-right3" className="font-14" />
</Button>
) : null} */}
</div>
</div>
</div>
</Card.Header>
<Card.Footer className="border- w-100 px-0 p-0 overflow-hidden">
<Row xs={3} className="pt-0 g-0">
{CAMPAIGN_RANK_BLOCKS.map((block) => {
const { className: rankClassName, style } = getCampaignRankBlockAppearance(
campaign,
block
);
const iconWrapperStyle = getCampaignRankIconStyle(block);
return (
<Col key={block.rankType}>
<button
type="button"
className={`${rankClassName} dashboard-campaign-rank-hit rounded- p-2 pt-4 pb-3 text-center h-100 w-100${style?.border ? "" : " border-0"}`}
style={style}
onClick={(e) => {
e.stopPropagation();
onRankClick?.(block.rankType);
}}
disabled={!onRankClick}
aria-label={`View ${block.label} leaderboard`}
>
<div
style={iconWrapperStyle ?? undefined}
className="rounded-3 d-flex justify-content-center align-items-center campaign-rank-icon-wrap"
>
<CustomIcon
tag={getCampaignRankIconTag(block)}
className="font-14 text-white"
/>
</div>
<div className="extra-info-card-count mb-1">
{readCampaignRankDisplay(campaign, block)}
</div>
<div className="extra-info-card-title mb-0">{block.label}</div>
</button>
</Col>
);
})}
{/* Keep up the amazing work! */}
{campaignMessage && (
<Col xs={12} className="text-center p-1 py-2" style={{ backdropFilter: "blur(5px)" }}><SectionHeading size="" className="fw-n font-10"><CustomIcon tag="insights7" className="font-13 text-white me-1"/> <span style={{ color: "rgb(255 254 246)" }}>{campaignMessage}</span></SectionHeading></Col>
)}
</Row>
</Card.Footer>
</Card>
);
}
export default CampaignSummaryCard;

View File

@ -0,0 +1,13 @@
.dashboard-campaign-rank-hit {
cursor: pointer;
transition: opacity 0.15s ease;
}
.dashboard-campaign-rank-hit:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.dashboard-campaign-rank-hit:not(:disabled):active {
opacity: 0.85;
}

View File

@ -0,0 +1,35 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { getCampaignId } from "../../util/campaignHelpers";
import CampaignSummaryCard from "./CampaignSummaryCard";
import "./DashboardCampaignsCard.css";
function DashboardCampaignsCard({ campaign }) {
const navigate = useNavigate();
const campaignId = getCampaignId(campaign);
const openLeaderboard = (rankType) => {
if (!campaignId) return;
navigate(
`/campaigns/${encodeURIComponent(campaignId)}/leaderboard?rank_type=${encodeURIComponent(rankType)}`,
{ state: { pageTitle: "Campaign Leaderboard" } }
);
};
return (
<CampaignSummaryCard
campaign={campaign}
className="card-slide mt-3"
showNavigateButton
onHeaderClick={() => {
if (!campaignId) return;
navigate(`/campaigns/${encodeURIComponent(campaignId)}`, {
state: { pageTitle: "Campaign Details" },
});
}}
onRankClick={openLeaderboard}
/>
);
}
export default DashboardCampaignsCard;

View File

@ -0,0 +1,143 @@
.card-theme.dashboard-campaigns-container {
}
.card-theme.dashboard-campaigns-card {
background: #189ff2;
background: linear-gradient(139deg, rgba(3, 140, 224,0.83) 0%, rgba(123, 139, 205, 0.38) 100%);
padding: 10px 15px 12px;
}
.card-theme.dashboard-campaigns-card .card-header {
background-color: transparent;
color: var(--color-gray1);
font-weight: var(--fw-sb);
font-size: 15px;
padding: 10px 12px;
border-bottom: 0px dashed var(--color-secondary-dark);
}
.campaign-icon-wrapper {
position: relative;
}
/* .campaign-icon-wrapper::before {
position: absolute;
content: '';
width: 100%;
height: 100%;
background-color: rgba(255,255,255,0.15);
left: 0;
right: 0;
} */
.extra-info-card {
padding: 15px 10px;
text-align: center;
background-color: rgba(255,255,255,0.95);
color: var(--color-gray4);
position: relative;
}
.extra-info-card.bg-orange {
background: linear-gradient(180deg, rgba(255, 165, 0, 0.9) 0%, rgba(255, 165, 0, 0.4) 100%);
color: #fff;
}
.extra-info-card.bg-red {
background: linear-gradient(180deg, rgba(255, 66, 33, 0.83) 0%, rgba(255, 66, 33, 0.4) 100%);
color: #fff;
}
.extra-info-card.bg-green {
background: linear-gradient(180deg, rgba(18,196,110, 0.83) 0%, rgba(18,196,110, 0.4) 100%);
color: #fff;
}
.card-theme.dashboard-campaigns-card .card-footer .row .col:nth-child(2) .extra-info-card::before,
.card-theme.dashboard-campaigns-card .card-footer .row .col:nth-child(2) .extra-info-card::after {
content: '';
height: 60%;
width: 1px;
background-color: rgba(0,0,0,0.1);
position: absolute;
left: 0;
top: 0;
bottom: 0;
margin: auto;
}
.card-theme.dashboard-campaigns-card .card-footer .row .col:nth-child(2) .extra-info-card::after {
right: 0;
left: auto;
}
.extra-info-card.bg-orange::after,
.extra-info-card.bg-red::after,
.extra-info-card.bg-green::after,
.extra-info-card.bg-orange::before,
.extra-info-card.bg-red::before,
.extra-info-card.bg-green::before,
.extra-info-card.extra-info-card--theme-color::after,
.extra-info-card.extra-info-card--theme-color::before {
display: none;
}
.extra-info-card.extra-info-card--theme-color {
color: var(--color-gray4);
}
.extra-info-card.extra-info-card--static-rank::before,
.extra-info-card.extra-info-card--static-rank::after {
display: none;
}
.campaign-rank-icon-wrap {
width: 24px;
height: 24px;
margin: 0 auto 4px;
position: absolute;
top: 6px;
left: 7px;
}
.extra-info-card-title {
font-size: 13px;
font-weight: var(--fw-sb);
/* color: var(--color-gray3); */
margin-bottom: 12px;
}
.extra-info-card-count {
/* background-color: var(--color-gray-light5); */
/* background: linear-gradient(139deg, rgba(123, 139, 205, 0.38) 0%, rgba(3, 140, 224,0.3) 100%); */
/* width: 50px; */
/* height: 50px; */
/* border-radius: 50%; */
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: var(--fw-b);
/* color: var(--color-gray3); */
margin: 0 auto;
}
.card-theme.dashboard-campaigns-card .card-footer {
background-color: rgba(0,0,0,0.085);
border-radius: 12px;
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
backdrop-filter: blur(5px);
border: 1px solid rgba(154,157,223,0.16);
}
.extra-info-card-title {
position: relative;
}
.extra-info-card-title::before {
position: absolute;
content: '';
width: 25px;
height: 2px;
background-color: rgba(250,250,250,0.5);
left: 0;
right: 0;
margin: auto;
bottom: -5px;
border-radius: 10px;
}
@media screen and (max-width: 380px) {
.card-theme.dashboard-campaigns-card .card-header .section-heading.heading-lg { font-size: 14px;}
.extra-info-card-title { font-size: 12px;}
}

View File

@ -1,11 +1,16 @@
import { Container, Card, Row, Col, Button } from "react-bootstrap";
import { useNavigate } from "react-router-dom";
import { Container } from "react-bootstrap";
import { useQuery } from "@tanstack/react-query";
import dashboardService from "../../services/Dashboard";
import "./DashboardCampaignsContainer.css";
import { Swiper, SwiperSlide } from "swiper/react";
import { Autoplay, Pagination } from "swiper/modules";
import "swiper/css/bundle";
import DashboardCampaignsCard from "./DashboardCampaignsCard";
/** Set to false after verifying Swiper with multiple slides */
const DUPLICATE_CAMPAIGN_SLIDES_FOR_TEST = false;
function DashboardCampaignsContainer() {
const navigate = useNavigate();
const { data: campaignRes } = useQuery({
queryKey: ["dashboardCampaignList"],
queryFn: async () => {
@ -14,64 +19,45 @@ function DashboardCampaignsContainer() {
},
});
const campaigns = Array.isArray(campaignRes?.data)
const campaignsFromApi = Array.isArray(campaignRes?.data)
? campaignRes.data
: campaignRes?.data?.items || campaignRes?.data?.campaigns || [];
const campaigns = DUPLICATE_CAMPAIGN_SLIDES_FOR_TEST
? [...campaignsFromApi, ...campaignsFromApi]
: campaignsFromApi;
const hasCampaigns = Array.isArray(campaigns) && campaigns.length > 0;
const campaign = hasCampaigns ? campaigns[0] : null;
if (!hasCampaigns) return null;
return (
<Container className="pt-1 pb-1 py-sm-3">
<Container className="pt-2 pb-3 py-sm-3">
<div className="d-flex align-items-center justify-content-between">
<h4 className="section-title mb-0">Campaigns</h4>
</div>
<Card className="card-theme border-0 shadow-sm p-3 rounded-4 mt-3">
<h5 className="mb-3 fw-bold">
{campaign?.name || campaign?.title || "Campaign"}
</h5>
<Row className="g-2 mb-3">
<Col xs={4}>
<div className="border border-2 border-success rounded-3 p-2 text-center h-100 d-flex flex-column justify-content-center">
<div className="text-muted font-12 mb-1">Overall Rank</div>
<div className="fw-bold fs-4">
{campaign?.overall_rank ?? campaign?.overallRank ?? "-"}
</div>
</div>
</Col>
<Col xs={4}>
<div className="border border-2 border-success rounded-3 p-2 text-center h-100 d-flex flex-column justify-content-center">
<div className="text-muted font-12 mb-1">Rank in Team</div>
<div className="fw-bold fs-4">
{campaign?.rank_in_team ?? campaign?.rankInTeam ?? "-"}
</div>
</div>
</Col>
<Col xs={4}>
<div className="border border-2 border-success rounded-3 p-2 text-center h-100 d-flex flex-column justify-content-center">
<div className="text-muted font-12 mb-1">Team Rank</div>
<div className="fw-bold fs-4">
{campaign?.team_rank ?? campaign?.teamRank ?? "-"}
</div>
</div>
</Col>
</Row>
<Button
type="button"
className="w-100 py-3 rounded-4 border-0 fw-semibold"
style={{ backgroundColor: "#5B7FC4" }}
onClick={() => navigate("/campaigns", { state: { campaign } })}
>
View Details
</Button>
</Card>
<Swiper
modules={[Autoplay, Pagination]}
slidesPerView={1}
slidesPerGroup={1}
spaceBetween={30}
grabCursor
autoplay={{
delay: 5000,
disableOnInteraction: false,
}}
pagination={{ clickable: true }}
className="theme-swiper dashboard-swiper dashboard-campaigns-swiper mb-2 position-relative"
>
{campaigns.map((item, index) => (
<SwiperSlide
key={`${item?.id ?? item?.campaign_id ?? item?.slug ?? "campaign"}-${index}`}
>
<DashboardCampaignsCard campaign={item} />
</SwiperSlide>
))}
</Swiper>
</Container>
);
}
export default DashboardCampaignsContainer;

View File

@ -6,6 +6,15 @@ import { handleAlert } from "../../util/util";
//custom hook
import useGetVerticalScrollDirection from "../UI/VerticalScrollDirection/useGetVerticalScrollDirection";
function pathnameHidesFooterNav(pathname) {
if (!pathname) return false;
if (pathname === "/metric-reports") return true;
if (/^\/metric-reports\/[^/]+$/.test(pathname)) return true;
if (/^\/campaigns\/[^/]+\/leaderboard$/.test(pathname)) return true;
if (/^\/campaigns\/[^/]+$/.test(pathname)) return true;
return false;
}
function DashboardFooter(props) {
const navigate = useNavigate();
const page = props.page;
@ -31,8 +40,9 @@ function DashboardFooter(props) {
expand="lg"
fixed="bottom"
className={`navbar-footer ${
// page !== "appreciations" ? "" : "navbar-hidden"
state?.hideFooterNav ? "navbar-hidden" : ""
state?.hideFooterNav || pathnameHidesFooterNav(pathname)
? "navbar-hidden"
: ""
} ${direction !== "down" ? "" : "navbar-hidden"}
${
page === "messages" && pathname.split("/").length > 2

View File

@ -44,34 +44,67 @@ function DashboardHeader(props) {
// navigate
// );
const pathSegments = location.pathname.split("/").filter((item) => item !== "");
const isCampaignDetails =
pathSegments[0] === "campaigns" && pathSegments.length === 2;
const isCampaignLeaderboard =
pathSegments[0] === "campaigns" &&
pathSegments.length >= 3 &&
pathSegments[2] === "leaderboard";
const isMetricReportsList =
pathSegments[0] === "metric-reports" && pathSegments.length === 1;
const isMetricReportDetail =
pathSegments[0] === "metric-reports" && pathSegments.length === 2;
let tempTitle = getPageTitle(pageName);
if (location.state?.pageTitle) {
if (isCampaignDetails) {
tempTitle = "Campaign Details";
} else if (isCampaignLeaderboard) {
tempTitle = "Campaign Leaderboard";
} else if (isMetricReportsList) {
tempTitle = "Performance Stats";
} else if (isMetricReportDetail) {
tempTitle = "Metric Details";
} else if (location.state?.pageTitle) {
tempTitle = location.state?.pageTitle;
// console.log("tempTitle Inner = ", tempTitle);
}
// console.log("tempTitle outer = ", tempTitle);
setPageTitle(tempTitle);
const tempPathArr = location.pathname.split("/");
const newTempPathArr = tempPathArr.filter((item, index) => item !== "");
const newTempPathArr = tempPathArr.filter((item) => item !== "");
let backLinkPath = "";
if (newTempPathArr.length > 2) {
backLinkPath = `/${newTempPathArr[0]}/${newTempPathArr[1]}`;
} else {
if (newTempPathArr.length > 1) {
backLinkPath = `/${newTempPathArr[0]}`;
if (pageName === "approve-reject-form-submission")
backLinkPath = `/notifications`;
} else {
backLinkPath = `/dashboard`;
if (newTempPathArr[0] === "campaigns") {
if (newTempPathArr.length === 2) {
// /campaigns/:id — opened from dashboard campaign card
backLinkPath = "/dashboard";
} else if (newTempPathArr.length >= 3) {
// e.g. /campaigns/:id/leaderboard → back to campaign details
backLinkPath = `/campaigns/${newTempPathArr[1]}`;
}
} else if (newTempPathArr[0] === "metric-reports") {
if (newTempPathArr.length >= 2) {
backLinkPath = "/metric-reports";
} else {
backLinkPath = "/dashboard";
}
} else if (newTempPathArr.length > 2) {
backLinkPath = `/${newTempPathArr[0]}/${newTempPathArr[1]}`;
} else if (newTempPathArr.length > 1) {
backLinkPath = `/${newTempPathArr[0]}`;
if (pageName === "approve-reject-form-submission" || pageName === "approve-reject-contact-submission") {
backLinkPath = `/notifications`;
}
} else {
backLinkPath = `/dashboard`;
}
if (backLinkPath !== "") {
setBackLink(backLinkPath);
}
// const backLinkPath = backLinkPathArr.join("/");
// console.log("STATE =", location.state);
}, [location.pathname, location.state?.pageTitle]);
}, [location.pathname, location.state?.pageTitle, location.state?.reportTitle, pageName]);
const [showLogoutConfirmationModal, setShowLogoutConfirmationModal] =
useState(false);

View File

@ -0,0 +1,57 @@
import React, { useMemo } from "react";
import { Badge, Card } from "react-bootstrap";
import MetricReportValuePanel from "../MetricReports/MetricReportValuePanel";
import {
formatMetricReportAggTypeLabel,
getMetricReportCurrentPeriod,
getMetricReportItemId,
listItemToValueGraph,
} from "../../util/metricReportListUtil";
/**
* List page card — uses value data from metric/metric_report/list (same shape as Value tab).
*/
function PerformanceStatValueCard({ item, onOpen }) {
const metricViewId = getMetricReportItemId(item);
const graph = useMemo(() => listItemToValueGraph(item), [item]);
const currentPeriod = getMetricReportCurrentPeriod(item);
const aggTypeLabel = formatMetricReportAggTypeLabel(item?.agg_type);
const showBadges = Boolean(currentPeriod || aggTypeLabel);
return (
<Card
className="border-0 shadow-sm card-theme metric-content-card performance-stat-value-card"
role={onOpen ? "button" : undefined}
onClick={onOpen ? () => onOpen(item) : undefined}
style={onOpen ? { cursor: "pointer" } : undefined}
>
{showBadges ? (
<div
className="performance-stat-value-card__badges"
aria-label={`${currentPeriod || ""}${currentPeriod && aggTypeLabel ? ", " : ""}${aggTypeLabel || ""}`}
>
{currentPeriod ? (
<Badge className="performance-stat-value-card__badge bg-transparent">
{currentPeriod}
</Badge>
) : null}
{aggTypeLabel ? (
<Badge className="performance-stat-value-card__badge bg-warning">
{aggTypeLabel}
</Badge>
) : null}
</div>
) : null}
<Card.Body className={`p-1${showBadges ? " performance-stat-value-card__body--badged" : ""}`}>
<MetricReportValuePanel
graph={graph}
isLoading={false}
isError={false}
missingId={!metricViewId}
/>
</Card.Body>
</Card>
);
}
export default PerformanceStatValueCard;

View File

@ -4,6 +4,12 @@ import { Container, Card, Button } from "react-bootstrap";
import CustomIcon from "../CustomIcon/CustomIcon";
import PlaceholderCard from "../PlaceholderCard/PlaceholderCard";
import StatsCard from "../StatsCard/StatsCard";
import {
getMetricReportDefaultTab,
metricReportDetailPath,
METRIC_REPORTS_LIST_PATH,
parseMetricViewIdFromReportUrl,
} from "../../util/metricReportListUtil";
//React Query
import { useQuery } from "@tanstack/react-query";
@ -11,21 +17,6 @@ import { useQuery } from "@tanstack/react-query";
//Services
import dashboardService from "../../services/Dashboard";
function parseMetricViewIdFromReportUrl(reportViewUrl) {
if (!reportViewUrl || typeof reportViewUrl !== "string") return null;
try {
const u = new URL(
reportViewUrl.startsWith("http")
? reportViewUrl
: `https://placeholder.local${reportViewUrl.startsWith("/") ? "" : "/"}${reportViewUrl}`
);
return u.searchParams.get("id");
} catch {
const m = String(reportViewUrl).match(/[?&]id=([^&]+)/);
return m ? decodeURIComponent(m[1]) : null;
}
}
function PerformanceStatsContainer() {
const navigate = useNavigate();
const [isRefresh, setIsRefresh] = useState(false);
@ -76,15 +67,10 @@ function PerformanceStatsContainer() {
return;
}
const title = item?.value_title || item?.title || "Metric report";
const aggType = String(item?.agg_type || "").toLowerCase();
const defaultTab =
aggType === "team" ? "Detail" : aggType === "individual" ? "Trend" : "Detail";
navigate("/metric-reports", {
navigate(metricReportDetailPath(metricViewId), {
state: {
metricViewId,
reportTitle: title,
pageTitle: title,
defaultTab,
defaultTab: getMetricReportDefaultTab(item),
},
});
};
@ -140,7 +126,10 @@ function PerformanceStatsContainer() {
<Button
variant="link"
size="sm"
className="btn-theme font-12 fw-sb pe-0 d-none"
className="btn-theme font-13 fw-sb pe-0"
onClick={() =>
navigate(METRIC_REPORTS_LIST_PATH)
}
>
View all
</Button>

View File

@ -0,0 +1,57 @@
import React from "react";
import { Modal, Button } from "react-bootstrap";
/**
* Simple export chooser: PNG and/or CSV download actions.
*/
function ExportFormatModal({
show,
onHide,
title = "Download",
onDownloadPng,
onDownloadCsv,
disablePng = false,
disableCsv = false,
}) {
const handlePng = () => {
onHide();
onDownloadPng?.();
};
const handleCsv = () => {
onHide();
onDownloadCsv?.();
};
return (
<Modal show={show} onHide={onHide} centered>
<Modal.Header closeButton className="border-botto border-0">
<Modal.Title className="fs-6">{title}</Modal.Title>
</Modal.Header>
<Modal.Body className="d-flex flex-colum gap-2 pt-3">
{onDownloadPng ? (
<Button
type="button"
className="w-100 d-flex align-items-center justify-content-center gap- btn-theme btn-badges font-14 p-2"
onClick={handlePng}
disabled={disablePng}
>
Download PNG
</Button>
) : null}
{onDownloadCsv ? (
<Button
type="button"
className="w-100 d-flex align-items-center justify-content-center gap- text-white btn-theme btn-green p-2 font-14"
onClick={handleCsv}
disabled={disableCsv}
>
Download CSV
</Button>
) : null}
</Modal.Body>
</Modal>
);
}
export default ExportFormatModal;

View File

@ -0,0 +1,109 @@
.leaderboard-export-modal .leaderboard-export-fields-panel {
border: 1px solid var(--color-gray-light3, #dee2e6);
border-radius: 0.5rem;
background: var(--color-gray-light, #f8f9fa);
max-height: 11rem;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 0.35rem;
}
.leaderboard-export-modal .leaderboard-export-field-row {
display: flex;
align-items: center;
gap: 0.65rem;
min-height: 2.5rem;
padding: 0.4rem 0.65rem;
margin-bottom: 0.25rem;
border-radius: 0.4rem;
border: 1px solid transparent;
background: #fff;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
transition:
background-color 0.15s ease,
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.leaderboard-export-modal .leaderboard-export-field-row:last-child {
margin-bottom: 0;
}
.leaderboard-export-modal .leaderboard-export-field-row:active {
background: var(--color-secondary-lighter, #e8eeff);
}
.leaderboard-export-modal .leaderboard-export-field-row.is-checked {
/* border-color: rgba(89, 112, 172, 0.35); */
background: rgba(202, 216, 251, 0.35);
}
.leaderboard-export-modal .leaderboard-export-field-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.leaderboard-export-modal .leaderboard-export-field-check {
flex-shrink: 0;
width: 1.1rem;
height: 1.1rem;
border-radius: 0.3rem;
border: 1px solid var(--color-gray-light2, #adb5bd);
background: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
background-color 0.15s ease,
border-color 0.15s ease;
}
.leaderboard-export-modal .leaderboard-export-field-row.is-checked .leaderboard-export-field-check {
border-color: var(--color-secondary-dark, #5970ac);
background: var(--color-secondary-dark, #5970ac);
}
.leaderboard-export-modal .leaderboard-export-field-check-icon {
width: 0.95rem;
height: 0.95rem;
color: #fff;
opacity: 0;
transform: scale(0.6);
transition:
opacity 0.12s ease,
transform 0.12s ease;
}
.leaderboard-export-modal .leaderboard-export-field-row.is-checked .leaderboard-export-field-check-icon {
opacity: 1;
transform: scale(1);
}
.leaderboard-export-modal .leaderboard-export-field-label {
flex: 1;
font-size: 0.875rem;
line-height: 1.3;
color: var(--color-gray5, #212529);
/* font-weight: 500; */
}
.leaderboard-export-modal .leaderboard-export-field-row.is-checked .leaderboard-export-field-label {
color: var(--color-secondary-dark, #5970ac);
}
.leaderboard-export-modal .leaderboard-export-fields-empty {
display: block;
padding: 0.75rem;
font-size: 0.875rem;
color: var(--color-gray4, #6c757d);
text-align: center;
}

View File

@ -0,0 +1,166 @@
import React, { useCallback } from "react";
import { Modal, Button, Form } from "react-bootstrap";
import CustomIcon from "../CustomIcon/CustomIcon";
import SectionHeading from "../UI/SectionHeading/SectionHeading";
import "./LeaderboardExportModal.css";
/**
* Shared export options modal for Metric Report and Campaign leaderboards.
*/
function LeaderboardExportModal({
show,
onHide,
title = "Export Leaderboard",
showAdditionalFields = true,
alwaysIncludedHint = "Rank, User, Emp Code, Position Name, and Points/Units are always included. Select any extra columns below (optional).",
additionalFieldOptions = [],
exportForm,
setExportForm,
onConfirm,
}) {
const toggleAdditionalField = useCallback(
(fieldId, nextChecked) => {
setExportForm((f) => {
const current = f.additionalFieldIds || [];
const shouldCheck =
typeof nextChecked === "boolean"
? nextChecked
: !current.includes(fieldId);
return {
...f,
additionalFieldIds: shouldCheck
? current.includes(fieldId)
? current
: [...current, fieldId]
: current.filter((x) => x !== fieldId),
};
});
},
[setExportForm]
);
return (
<Modal
show={show}
onHide={onHide}
centered
className="leaderboard-export-modal"
>
<Modal.Header closeButton className="border-bottom">
<Modal.Title className="fs-6">{title}</Modal.Title>
</Modal.Header>
<Modal.Body>
<Form.Group className="mb-3">
<Form.Label className="small">Select top &quot;x&quot; ranks</Form.Label>
<Form.Control
size="sm"
type="number"
min={1}
value={exportForm.topRanks}
onChange={(e) =>
setExportForm((f) => ({ ...f, topRanks: e.target.value }))
}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label className="small">Select interval</Form.Label>
<Form.Select
size="sm"
value={exportForm.interval}
onChange={(e) =>
setExportForm((f) => ({ ...f, interval: e.target.value }))
}
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
</Form.Select>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label className="small">Select export format</Form.Label>
<Form.Select
size="sm"
value={exportForm.format}
onChange={(e) =>
setExportForm((f) => ({ ...f, format: e.target.value }))
}
>
<option value="csv">CSV</option>
<option value="png">PNG</option>
</Form.Select>
</Form.Group>
{showAdditionalFields ? (
<Form.Group className="mb-3">
<Form.Label className="small fw-semibold mb-1">
Include additional fields
</Form.Label>
<SectionHeading size="xs" className="text-muted mb-2">
{alwaysIncludedHint}
</SectionHeading>
<div
className="leaderboard-export-fields-panel"
role="group"
aria-label="Additional export columns"
>
{additionalFieldOptions.length === 0 ? (
<span className="leaderboard-export-fields-empty">
No additional fields available.
</span>
) : (
additionalFieldOptions.map((field) => {
const fieldId = field.id ?? field.accessorKey;
const checked = (exportForm.additionalFieldIds || []).includes(
fieldId
);
const inputId = `leaderboard-export-field-${fieldId}`;
return (
<label
key={fieldId}
htmlFor={inputId}
className={`leaderboard-export-field-row${checked ? " is-checked" : ""}`}
>
<input
id={inputId}
type="checkbox"
className="leaderboard-export-field-input"
checked={checked}
onChange={(e) =>
toggleAdditionalField(fieldId, e.target.checked)
}
/>
<span className="leaderboard-export-field-check" aria-hidden>
<CustomIcon
tag="check1"
className="leaderboard-export-field-check-icon"
/>
</span>
<span className="leaderboard-export-field-label">
{field.header}
</span>
</label>
);
})
)}
</div>
</Form.Group>
) : (
<p className="small text-muted mb-3">
Export includes all columns shown in the leaderboard table.
</p>
)}
<Button
variant="info"
className="w-100 d-flex align-items-center justify-content-center gap-2 text-white"
style={{ backgroundColor: "#14b8a6", borderColor: "#14b8a6" }}
onClick={onConfirm}
>
<CustomIcon tag="download1" className="font-14" />
Download
</Button>
</Modal.Body>
</Modal>
);
}
export default LeaderboardExportModal;

View File

@ -0,0 +1,180 @@
import React from "react";
import {
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Button, ButtonGroup } from "react-bootstrap";
/** Sticky leading column classes per table layout (horizontal scroll freeze). */
const STICKY_LEADING_CLASS_SETS = {
leaderboard: [
"metric-sticky-col-leader-1",
"metric-sticky-col-leader-2",
],
tabular: ["metric-sticky-col-tab-1", "metric-sticky-col-tab-2"],
};
/**
* TanStack-powered data table with pagination.
* Use the header download control on Metric Reports for CSV export.
*
* @param {number} stickyLeadingColumns — freeze first N columns (max 2).
* @param {"leaderboard"|"tabular"} stickyColumnSet — CSS layout for frozen columns.
* @param {boolean} stickyFirstColumn — deprecated; same as stickyLeadingColumns={1}
*/
function MetricReportDataTable({
columns,
data,
stickyFirstColumn = false,
stickyLeadingColumns = 0,
stickyColumnSet = "leaderboard",
pageSize = 25,
getRowClassName,
}) {
const stickyLeadingClasses =
STICKY_LEADING_CLASS_SETS[stickyColumnSet] ??
STICKY_LEADING_CLASS_SETS.leaderboard;
const leadingStickyCount = (() => {
if (stickyLeadingColumns > 0) {
return Math.min(stickyLeadingColumns, stickyLeadingClasses.length);
}
return stickyFirstColumn ? 1 : 0;
})();
const table = useReactTable({
data: data ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
initialState: { pagination: { pageSize } },
});
if (!Array.isArray(data) || data.length === 0) {
return (
<div className="text-center text-muted small py-4">No data.</div>
);
}
return (
<div className="metric-report-datatable">
<div className="table-responsive metric-table-wrap">
<table className="table mb-0 metric-design-table">
<thead>
{table.getHeaderGroups().map((hg) => (
<tr key={hg.id}>
{hg.headers.map((header, hi) => {
const stickyLegacy =
leadingStickyCount === 1 &&
hi === 0 &&
(header.column.columnDef.meta?.sticky ||
header.column.id === "rank");
const stickyIndexed =
hi < leadingStickyCount &&
hi < stickyLeadingClasses.length &&
(stickyColumnSet === "tabular" || leadingStickyCount > 1);
const stickyClass = stickyIndexed
? stickyLeadingClasses[hi]
: stickyLegacy
? "metric-sticky-col"
: undefined;
return (
<th
key={header.id}
className={stickyClass}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row, ri) => {
const extraClass =
typeof getRowClassName === "function"
? getRowClassName(row.original, ri) || ""
: "";
const zebra = ri % 2 === 0 ? "bg-white" : "table-light";
return (
<tr
key={row.id}
className={[zebra, extraClass].filter(Boolean).join(" ")}
>
{row.getVisibleCells().map((cell, ci) => {
const stickyLegacy =
leadingStickyCount === 1 &&
ci === 0 &&
(cell.column.columnDef.meta?.sticky ||
cell.column.id === "rank");
const stickyIndexed =
ci < leadingStickyCount &&
ci < stickyLeadingClasses.length &&
(stickyColumnSet === "tabular" || leadingStickyCount > 1);
const stickyClass = stickyIndexed
? stickyLeadingClasses[ci]
: stickyLegacy
? "metric-sticky-col"
: undefined;
const zebra =
ri % 2 === 0 ? "bg-white" : "table-light";
return (
<td
key={cell.id}
className={[stickyClass, stickyClass ? zebra : ""]
.filter(Boolean)
.join(" ")}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
{table.getPageCount() > 1 ? (
<div className="d-flex flex-wrap align-items-center justify-content-between gap-2 px-2 py-2 border-top">
<span className="small text-secondary">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<ButtonGroup size="sm">
<Button
type="button"
variant="outline-secondary"
disabled={!table.getCanPreviousPage()}
onClick={() => table.previousPage()}
>
Prev
</Button>
<Button
type="button"
variant="outline-secondary"
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}
>
Next
</Button>
</ButtonGroup>
</div>
) : null}
</div>
);
}
export default MetricReportDataTable;

View File

@ -0,0 +1,205 @@
import React, { useLayoutEffect, useRef } from "react";
import { toMetricNumber } from "./metricReportValueUtil";
import "./metricChartAnimations.css";
const CX = 110;
const CY = 110;
const OUTER_R = 92;
const INNER_R = 56;
const RING_STROKE = OUTER_R - INNER_R;
const RING_R = (OUTER_R + INNER_R) / 2;
const SLICE_GAP_DEG = 0.65;
function polarToCartesian(cx, cy, r, angleDeg) {
const angleRad = ((angleDeg - 90) * Math.PI) / 180.0;
return {
x: cx + r * Math.cos(angleRad),
y: cy + r * Math.sin(angleRad),
};
}
/** Open arc along the donut midline (stroke draws this path). */
function describeDonutArcPath(cx, cy, r, startAngleDeg, endAngleDeg) {
const start = polarToCartesian(cx, cy, r, startAngleDeg);
const end = polarToCartesian(cx, cy, r, endAngleDeg);
let sweep = endAngleDeg - startAngleDeg;
if (sweep <= 0) return "";
const largeArc = sweep > 180 ? 1 : 0;
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${largeArc} 1 ${end.x} ${end.y}`;
}
function arcLengthForDegrees(degrees, radius) {
return (Math.max(0, degrees) / 360) * 2 * Math.PI * radius;
}
const COLORS = [
"#00bcd4",
"#4A5CD0",
"#01C14B",
"#FF8050",
"#FECD00",
"#9CA3AF",
"#A855F7",
"#F97316",
"#14B8A6",
];
function PieArcSegment({ d, arcLength, color, delayMs }) {
const ref = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el || !d) return;
const len = arcLength > 0 ? arcLength : el.getTotalLength?.() || 0;
if (!len) return;
el.style.strokeDasharray = `${len}`;
el.style.strokeDashoffset = `${len}`;
}, [d, arcLength]);
if (!d) return null;
return (
<path
ref={ref}
className="metric-pie-arc"
d={d}
fill="none"
stroke={color}
strokeWidth={RING_STROKE}
strokeLinecap="butt"
style={{ animationDelay: `${delayMs}ms` }}
/>
);
}
function PieRingCircle({ color, delayMs }) {
const ref = useRef(null);
const circumference = 2 * Math.PI * RING_R;
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
el.style.strokeDasharray = `${circumference}`;
el.style.strokeDashoffset = `${circumference}`;
}, [circumference]);
return (
<circle
ref={ref}
className="metric-pie-arc metric-pie-ring"
cx={CX}
cy={CY}
r={RING_R}
fill="none"
stroke={color}
strokeWidth={RING_STROKE}
style={{ animationDelay: `${delayMs}ms` }}
/>
);
}
function MetricReportPieChart({ slices }) {
const safeSlices = Array.isArray(slices) ? slices : [];
const total = safeSlices.reduce((acc, s) => acc + toMetricNumber(s?.value), 0);
const positive = safeSlices
.map((s, idx) => ({
value: toMetricNumber(s?.value),
label: s?.label,
color: COLORS[idx % COLORS.length],
}))
.filter((s) => s.value > 0);
const multi = positive.length > 1;
let cursor = 0;
const segments = positive.map((slice, idx) => {
const share = total ? (slice.value / total) * 360 : 0;
const isFullRing = share >= 359.99;
const gap = multi && !isFullRing ? SLICE_GAP_DEG : 0;
const drawDeg = Math.max(0, share - gap);
const start = cursor + (multi ? gap / 2 : 0);
const end = start + drawDeg;
cursor += share;
const d = isFullRing
? null
: describeDonutArcPath(CX, CY, RING_R, start, end);
const arcLength = isFullRing
? 2 * Math.PI * RING_R
: arcLengthForDegrees(drawDeg, RING_R);
return {
...slice,
d,
arcLength,
isFullRing,
delayMs: idx * 70,
};
});
let legendDelay = 200;
if (segments.length > 0) {
const last = segments[segments.length - 1];
legendDelay = last.delayMs + Math.min(520, last.arcLength * 1.2) + 80;
}
return (
<div className="d-flex flex-column align-items-center metric-chart-draw">
<svg viewBox="0 0 220 220" width="220" height="220" role="img" aria-label="Pie chart">
<rect x="0" y="0" width="220" height="220" fill="#fff" />
{segments.map((seg, i) =>
seg.isFullRing ? (
<PieRingCircle key={i} color={seg.color} delayMs={seg.delayMs} />
) : (
<PieArcSegment
key={i}
d={seg.d}
arcLength={seg.arcLength}
color={seg.color}
delayMs={seg.delayMs}
/>
)
)}
<circle cx={CX} cy={CY} r={INNER_R} fill="#fff" />
<text x={CX} y={CY - 2} textAnchor="middle" fontSize="18" fill="#111827" fontWeight="700">
{Math.round(total)}
</text>
<text x={CX} y={CY + 18} textAnchor="middle" fontSize="10" fill="#6b7280">
Total
</text>
</svg>
<div className="w-100 mt-2">
<div className="d-flex flex-column gap-2">
{segments.map((seg, i) => (
<div
key={i}
className="metric-chart-legend-row d-flex align-items-start justify-content-between gap-2"
style={{ animationDelay: `${legendDelay + i * 40}ms` }}
>
<div className="d-flex align-items-start gap-2 min-w-0">
<span
style={{
width: 10,
height: 10,
borderRadius: 2,
background: seg.color,
display: "inline-block",
marginTop: 4,
flexShrink: 0,
}}
/>
<div className="text-muted x-small text-truncate" title={seg.label}>
{seg.label}
</div>
</div>
<div className="fw-semibold x-small text-nowrap">{seg.value}</div>
</div>
))}
</div>
</div>
</div>
);
}
export default MetricReportPieChart;

View File

@ -0,0 +1,71 @@
import React from "react";
import { Spinner } from "react-bootstrap";
import SectionHeading from "../UI/SectionHeading/SectionHeading";
import MetricReportPieChart from "./MetricReportPieChart";
import { toMetricNumber } from "./metricReportValueUtil";
/**
* Value tab body — pie chart, total, and legend (same as Metric Reports → Value).
*/
function MetricReportValuePanel({
graph,
isLoading = false,
isError = false,
missingId = false,
captureRef,
className = "",
loadingMessage = "Loading details…",
}) {
if (missingId) {
return (
<div className={`text-center text-muted small py-4 ${className}`}>
Missing metric id.
</div>
);
}
if (isError) {
return (
<div className={`text-center text-danger small py-4 ${className}`}>
Failed to load value.
</div>
);
}
if (isLoading) {
return (
<div className={`text-center text-muted small py-4 ${className}`}>
<Spinner animation="border" variant="secondary" size="sm" />{" "}
<span className="ms-1 font-14">{loadingMessage}</span>
</div>
);
}
const value = toMetricNumber(graph?.value);
const slices = Array.isArray(graph?.values) ? graph.values : [];
return (
<div
ref={captureRef}
className={`metric-value-png-capture bg-white ${className}`.trim()}
>
<div className="d-flex flex-column justify-content-between align-items-center mb-2">
<SectionHeading size="" className="mb-2 text-center">
{graph?.name || "Value"}
</SectionHeading>
<SectionHeading size="" className="mb-1 fw-n">
{/* Total: */}
<span className="fw-b font-22">{value}</span>
</SectionHeading>
</div>
{slices.length > 0 ? (
<MetricReportPieChart slices={slices} />
) : (
<div className="text-center text-muted small pt-3 pb-2">No data.</div>
)}
</div>
);
}
export default MetricReportValuePanel;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,202 @@
import {
filterLeaderboardColumnsByRowMetrics,
getLeaderboardExportConfig,
normalizeLeaderboardTabularConfig,
resolveLeaderboardConfigValue,
} from "./leaderboardTabularUtil";
/** Metric Report export: always included (matched by API `field` path). */
export const METRIC_LEADERBOARD_EXPORT_DEFAULT_FIELDS = [
"rank",
"user.name",
"user.employee_code",
"score",
];
export function isMetricLeaderboardDefaultExportField(field) {
return METRIC_LEADERBOARD_EXPORT_DEFAULT_FIELDS.includes(
String(field || "").trim()
);
}
/**
* Split API `leaderboard_export` into fixed defaults and optional additional columns.
*/
export function parseMetricLeaderboardExportConfig(config) {
const allColumns = normalizeLeaderboardTabularConfig(config);
const defaultColumns = METRIC_LEADERBOARD_EXPORT_DEFAULT_FIELDS.map((field) =>
allColumns.find((c) => c.field === field)
).filter(Boolean);
const additionalOptions = allColumns
.filter((c) => !isMetricLeaderboardDefaultExportField(c.field))
.map((c) => ({
id: c.accessorKey,
accessorKey: c.accessorKey,
header: c.label,
field: c.field,
}));
return { defaultColumns, additionalOptions, allColumns };
}
export function getMetricLeaderboardExportFromApi(source) {
return parseMetricLeaderboardExportConfig(getLeaderboardExportConfig(source));
}
/**
* Campaign leaderboard: keep only `metrics.<id>` export/tabular columns
* whose id exists on at least one row's `metrics[]` array.
*/
export function applyRowMetricsToLeaderboardExportParsed(parsed, rawRows) {
const allColumns = filterLeaderboardColumnsByRowMetrics(
parsed.allColumns,
rawRows
);
const defaultColumns = METRIC_LEADERBOARD_EXPORT_DEFAULT_FIELDS.map((field) =>
allColumns.find((c) => c.field === field)
).filter(Boolean);
const additionalOptions = allColumns
.filter((c) => !isMetricLeaderboardDefaultExportField(c.field))
.map((c) => ({
id: c.accessorKey,
accessorKey: c.accessorKey,
header: c.label,
field: c.field,
}));
return { defaultColumns, additionalOptions, allColumns };
}
export function getDefaultLeaderboardExportAdditionalIds(additionalOptions) {
return (additionalOptions || []).map((o) => o.id);
}
export function buildMetricLeaderboardExportColumns(
additionalFieldIds,
{ defaultColumns, additionalOptions, allColumns }
) {
const selected = new Set(additionalFieldIds || []);
const additionalCols = (allColumns || [])
.filter(
(c) =>
!isMetricLeaderboardDefaultExportField(c.field) &&
selected.has(c.accessorKey)
)
.map((c) => ({
accessorKey: c.accessorKey,
header: c.label,
field: c.field,
}));
return [
...(defaultColumns || []).map((c) => ({
accessorKey: c.accessorKey,
header: c.label,
field: c.field,
})),
...additionalCols,
];
}
/**
* Build export rows from raw leaderboard API rows using field paths.
*/
export function prepareLeaderboardExportRowsFromRaw(
rawRows,
cols,
{ topRanks, pngMaxRows, format, enrichRow } = {}
) {
const topN = parseInt(topRanks, 10);
let rows = (Array.isArray(rawRows) ? rawRows : [])
.filter((row) => row != null && typeof row === "object")
.sort((a, b) => Number(a?.rank) - Number(b?.rank));
if (Number.isFinite(topN) && topN > 0) {
rows = rows.filter((r) => Number(r?.rank) <= topN);
}
if (format === "png" && pngMaxRows > 0 && rows.length > pngMaxRows) {
rows = rows.slice(0, pngMaxRows);
}
return rows.map((row) => {
const out = {};
cols.forEach((col) => {
out[col.accessorKey] = resolveLeaderboardConfigValue(row, col);
});
return enrichRow ? enrichRow(row, out) : out;
});
}
/** Always included in leaderboard CSV/PNG export modals (legacy fallback). */
export const LEADERBOARD_EXPORT_DEFAULT_COLUMNS = [
{ accessorKey: "rank", header: "Rank" },
{ accessorKey: "name", header: "User" },
{ accessorKey: "employee_code", header: "Emp Code" },
{ accessorKey: "territory", header: "Position Name" },
{ accessorKey: "units", header: "Points/Units" },
];
export const LEADERBOARD_EXPORT_STATIC_ADDITIONAL_FIELDS = [
{ id: "zone", accessorKey: "zone", header: "Zone" },
{ id: "region", accessorKey: "region", header: "Region" },
{ id: "area", accessorKey: "area", header: "Area" },
{ id: "role", accessorKey: "role", header: "Role" },
];
export const LEADERBOARD_EXPORT_FORM_DEFAULT = {
additionalFieldIds: [],
topRanks: "10",
interval: "monthly",
format: "csv",
};
export function buildLeaderboardAdditionalFieldOptions(
metrics = [],
{ includeRole = true } = {}
) {
const staticFields = includeRole
? LEADERBOARD_EXPORT_STATIC_ADDITIONAL_FIELDS
: LEADERBOARD_EXPORT_STATIC_ADDITIONAL_FIELDS.filter((f) => f.id !== "role");
const metricFields = (metrics || [])
.filter((m) => m?.metric != null && m.metric !== "")
.map((m) => ({
id: `m_${m.metric}`,
accessorKey: `m_${m.metric}`,
header: m?.display || m?.metric || String(m.metric),
}));
return [...staticFields, ...metricFields];
}
export function buildLeaderboardExportColumns(additionalFieldIds, metrics = []) {
const options = buildLeaderboardAdditionalFieldOptions(metrics);
const selected = new Set(additionalFieldIds || []);
const extraCols = options
.filter((o) => selected.has(o.id))
.map(({ accessorKey, header }) => ({ accessorKey, header }));
return [...LEADERBOARD_EXPORT_DEFAULT_COLUMNS, ...extraCols];
}
/**
* Filter/sort rows for export; normalize row keys to match selected columns.
*/
export function prepareLeaderboardExportRows(
tableRows,
cols,
{ topRanks, pngMaxRows, format, enrichRow } = {}
) {
const topN = parseInt(topRanks, 10);
let rows = [...(tableRows || [])].sort(
(a, b) => Number(a.rank) - Number(b.rank)
);
if (Number.isFinite(topN) && topN > 0) {
rows = rows.filter((r) => Number(r.rank) <= topN);
}
if (format === "png" && pngMaxRows > 0 && rows.length > pngMaxRows) {
rows = rows.slice(0, pngMaxRows);
}
return rows.map((r) => {
const o = {};
cols.forEach((col) => {
const key = col.accessorKey ?? col.id;
if (key == null) return;
o[key] = r[key] ?? "";
});
return enrichRow ? enrichRow(r, o) : o;
});
}

View File

@ -0,0 +1,244 @@
/**
* Leaderboard column layout from API `leaderboard_tabular_config` / `leaderboard_export`.
* Each entry: { label, field, id? } where field is a dot path on the raw row
* (e.g. rank, user.name, stats.ucount, stats.<metricId>, metrics.<metricId>).
* Campaign rows: per-row `metrics: [{ metric, display, units, weight }, ...]`.
*/
function unwrapApiPayload(source) {
if (!source || typeof source !== "object") return source;
if (
source.data != null &&
typeof source.data === "object" &&
!Array.isArray(source.data) &&
(source.statusCode != null || source.status != null || source.message != null)
) {
return source.data;
}
return source;
}
export function getLeaderboardTabularConfig(source) {
const body = unwrapApiPayload(source);
const fromGraph = body?.graph?.leaderboard_tabular_config;
if (Array.isArray(fromGraph) && fromGraph.length > 0) return fromGraph;
const fromRoot = body?.leaderboard_tabular_config;
if (Array.isArray(fromRoot) && fromRoot.length > 0) return fromRoot;
return [];
}
export function getLeaderboardExportConfig(source) {
const body = unwrapApiPayload(source);
const fromGraph = body?.graph?.leaderboard_export;
if (Array.isArray(fromGraph) && fromGraph.length > 0) return fromGraph;
const fromRoot = body?.leaderboard_export;
if (Array.isArray(fromRoot) && fromRoot.length > 0) return fromRoot;
return [];
}
export function normalizeLeaderboardTabularConfig(config) {
return (Array.isArray(config) ? config : [])
.filter((entry) => entry?.field != null && String(entry.field).trim() !== "")
.map((entry, index) => {
const field = String(entry.field).trim();
return {
label: entry?.label != null ? String(entry.label) : field,
field,
id: entry?.id != null && entry.id !== "" ? String(entry.id) : null,
accessorKey: leaderboardConfigAccessorKey(entry, field, index),
isRank: field === "rank",
};
});
}
function metricIdFromMetricsField(field, entryId) {
if (entryId) return String(entryId);
const match = String(field).match(/^metrics\.(.+)$/);
return match ? match[1] : null;
}
function metricIdFromStatsField(field, entryId) {
if (entryId) return String(entryId);
const match = String(field).match(/^stats\.(.+)$/);
return match ? match[1] : null;
}
function leaderboardConfigAccessorKey(entry, field, index) {
if (field.startsWith("metrics.")) {
const mid = metricIdFromMetricsField(field, entry?.id);
return mid ? `metrics_${mid}` : `metrics_${index}`;
}
if (field.startsWith("stats.")) {
const sid = metricIdFromStatsField(field, entry?.id);
return sid ? `stats_${sid}` : `stats_${index}`;
}
if (entry?.id) return `stats_${entry.id}`;
if (field === "rank") return "rank";
return field.replace(/\./g, "_") || `col_${index}`;
}
/** Metric ids present on campaign leaderboard rows (`row.metrics[].metric`). */
export function getMetricIdsFromLeaderboardRows(rawRows) {
const ids = new Set();
(Array.isArray(rawRows) ? rawRows : []).forEach((row) => {
if (!row || typeof row !== "object") return;
const metrics = row.metrics;
if (!Array.isArray(metrics)) return;
metrics.forEach((m) => {
if (m?.metric != null && String(m.metric).trim() !== "") {
ids.add(String(m.metric));
}
});
});
return ids;
}
export function isMetricsFieldPath(field) {
return String(field || "").startsWith("metrics.");
}
export function filterLeaderboardColumnsByRowMetrics(columns, rawRows) {
const metricIds = getMetricIdsFromLeaderboardRows(rawRows);
if (!metricIds.size) {
return (columns || []).filter((c) => !isMetricsFieldPath(c.field));
}
return (columns || []).filter((col) => {
if (!isMetricsFieldPath(col.field)) return true;
const mid = metricIdFromMetricsField(col.field, col.id);
return mid != null && metricIds.has(mid);
});
}
function findRowMetricEntry(row, metricId) {
if (!row || metricId == null) return null;
const metrics = row.metrics;
if (!Array.isArray(metrics)) return null;
return (
metrics.find((m) => m != null && String(m.metric) === String(metricId)) ||
null
);
}
function resolveRowMetricsFieldValue(row, field, entryId) {
const metricId = metricIdFromMetricsField(field, entryId);
if (!metricId) return undefined;
const entry = findRowMetricEntry(row, metricId);
if (!entry) return undefined;
if (entry.units !== undefined && entry.units !== null) return entry.units;
if (entry.value !== undefined && entry.value !== null) return entry.value;
return entry.score;
}
function getByPath(obj, path) {
if (obj == null || !path) return undefined;
const parts = String(path).split(".");
let cur = obj;
for (const part of parts) {
if (cur == null || typeof cur !== "object") return undefined;
cur = cur[part];
}
return cur;
}
function formatUserDisplayName(user) {
if (!user || typeof user !== "object") return "-";
return [user.name, user.designation ? ` (${user.designation})` : ""]
.filter(Boolean)
.join("");
}
function formatCellValue(raw, { field }) {
if (raw == null || raw === "") {
if (
field.startsWith("stats.") ||
field.startsWith("metrics.") ||
field === "score"
) {
return 0;
}
return "-";
}
if (typeof raw === "number" && Number.isFinite(raw)) {
return Math.round(raw * 100) / 100;
}
if (typeof raw === "boolean") return raw ? "Yes" : "No";
return String(raw);
}
/**
* Resolve one config column value from a raw leaderboard API row.
*/
export function resolveLeaderboardConfigValue(row, column) {
if (!row || !column?.field) return "-";
const { field } = column;
if (field === "user.name") {
return formatUserDisplayName(row.user) || "-";
}
if (field.startsWith("metrics.")) {
const raw = resolveRowMetricsFieldValue(row, field, column.id);
return formatCellValue(raw, column);
}
if (field.startsWith("stats.")) {
const statKey = metricIdFromStatsField(field, column.id);
const raw =
statKey != null && row?.stats && typeof row.stats === "object"
? row.stats[statKey]
: getByPath(row, field);
return formatCellValue(raw, column);
}
const raw = getByPath(row, field);
return formatCellValue(raw, column);
}
/** user_id / employee_code for current-user row highlight (UI + PNG). */
export function enrichMetricLeaderboardRow(rawRow, mappedRow) {
const u = rawRow?.user || {};
const employee_code =
mappedRow.user_employee_code ??
mappedRow.employee_code ??
u?.employee_code ??
"-";
return {
...mappedRow,
user_id: u?.id ?? u?.user_id ?? rawRow?.user_id ?? null,
employee_code,
};
}
export function enrichMetricLeaderboardTableExportRow(tableRow, exportRow) {
return {
...exportRow,
user_id: tableRow?.user_id ?? exportRow.user_id ?? null,
employee_code:
tableRow?.employee_code ??
exportRow.user_employee_code ??
exportRow.employee_code ??
"-",
};
}
export function mapLeaderboardRowsFromConfig(rawRows, columns, { enrichRow } = {}) {
if (!columns.length) return [];
return (Array.isArray(rawRows) ? rawRows : [])
.filter((row) => row != null && typeof row === "object")
.sort((a, b) => Number(a?.rank) - Number(b?.rank))
.map((row) => {
const out = {};
columns.forEach((col) => {
out[col.accessorKey] = resolveLeaderboardConfigValue(row, col);
});
return enrichRow ? enrichRow(row, out) : out;
});
}
/** Plain { accessorKey, header } for CSV/PNG export. */
export function leaderboardConfigToExportColumns(columns) {
return columns.map(({ accessorKey, label }) => ({
accessorKey,
header: label,
}));
}

View File

@ -0,0 +1,112 @@
/* Draw animations — GPU-friendly transforms / stroke-dash only */
.metric-chart-draw svg {
overflow: visible;
}
/* Value pie — donut arcs drawn with stroke-dashoffset */
.metric-pie-arc {
animation: metric-pie-arc-draw 0.52s ease-out forwards;
}
@keyframes metric-pie-arc-draw {
to {
stroke-dashoffset: 0;
}
}
/* Trend line */
.metric-line-path {
animation: metric-line-draw 0.45s ease-out forwards;
}
.metric-line-dot {
opacity: 0;
animation: metric-dot-in 0.22s ease-out forwards;
}
@keyframes metric-line-draw {
to {
stroke-dashoffset: 0;
}
}
@keyframes metric-dot-in {
to {
opacity: 1;
}
}
/* Trend bars */
.metric-bar-rect {
transform-box: fill-box;
transform-origin: center bottom;
transform: scaleY(0);
opacity: 0.85;
animation: metric-bar-grow 0.4s ease-out forwards;
}
@keyframes metric-bar-grow {
to {
transform: scaleY(1);
opacity: 0.85;
}
}
/* Legend rows (value pie) */
.metric-chart-legend-row {
opacity: 0;
animation: metric-legend-in 0.28s ease-out forwards;
}
@keyframes metric-legend-in {
to {
opacity: 1;
}
}
/* PNG export: final frame instantly — no logic changes, safe for iOS/Android capture */
.metric-chart-export-mode .metric-pie-arc,
.metric-chart-export-mode .metric-pie-ring,
.metric-chart-export-mode .metric-line-path,
.metric-chart-export-mode .metric-line-dot,
.metric-chart-export-mode .metric-bar-rect,
.metric-chart-export-mode .metric-chart-legend-row,
.metric-chart-export-mode.metric-chart-enter {
animation: none !important;
opacity: 1 !important;
transform: none !important;
stroke-dashoffset: 0 !important;
}
.metric-chart-export-mode .metric-chart-legend-row,
.metric-chart-export-mode .x-small {
font-size: 0.62rem !important;
}
.metric-chart-export-mode .metric-chart-legend-row .fw-semibold {
font-weight: 600 !important;
}
.metric-chart-export-mode .metric-bar-rect {
transform: scaleY(1) !important;
}
@media (prefers-reduced-motion: reduce) {
.metric-pie-arc,
.metric-pie-ring,
.metric-line-path,
.metric-line-dot,
.metric-bar-rect,
.metric-chart-legend-row,
.metric-chart-enter {
animation: none !important;
opacity: 1 !important;
transform: none !important;
stroke-dashoffset: 0 !important;
}
.metric-bar-rect {
transform: scaleY(1) !important;
}
}

View File

@ -0,0 +1,13 @@
import { useLayoutEffect } from "react";
/** Sets stroke-dasharray/offset for line-draw CSS animation. */
export function useLinePathDraw(pathRef, pathD) {
useLayoutEffect(() => {
const path = pathRef.current;
if (!path || !pathD) return;
const len = path.getTotalLength();
if (!Number.isFinite(len) || len <= 0) return;
path.style.strokeDasharray = `${len}`;
path.style.strokeDashoffset = `${len}`;
}, [pathD]);
}

View File

@ -0,0 +1,50 @@
import { waitForNextPaint } from "../../util/downloadBlob";
/** Longest chart draw animation (ms) — keep in sync with metricChartAnimations.css */
export const METRIC_CHART_DRAW_MS = 480;
const CHART_CAPTURE_SELECTOR =
".metric-value-png-capture, .metric-trend-png-capture";
export function isMetricChartCaptureElement(element) {
if (!element?.classList) return false;
return (
element.classList.contains("metric-value-png-capture") ||
element.classList.contains("metric-trend-png-capture")
);
}
/**
* Forces charts to their final frame before PNG capture (no half-drawn lines/bars).
* @returns cleanup
*/
export function prepareChartsForExport(rootElement) {
const targets = new Set();
if (rootElement && isMetricChartCaptureElement(rootElement)) {
targets.add(rootElement);
}
if (rootElement?.querySelectorAll) {
rootElement.querySelectorAll(CHART_CAPTURE_SELECTOR).forEach((el) => {
targets.add(el);
});
}
targets.forEach((el) => {
el.classList.add("metric-chart-export-mode");
el.querySelectorAll(".metric-line-path, .metric-pie-arc").forEach((path) => {
path.style.strokeDashoffset = "0";
});
});
return () => {
targets.forEach((el) => {
el.classList.remove("metric-chart-export-mode");
});
};
}
/** Let draw animations finish on screen; export uses prepareChartsForExport instead. */
export async function waitForChartDrawAnimations() {
await waitForNextPaint();
await new Promise((resolve) => setTimeout(resolve, METRIC_CHART_DRAW_MS + 40));
}

View File

@ -0,0 +1,164 @@
import { saveBlobWithDownloadUI } from "../../util/downloadBlob";
export function escapeCsvCell(value) {
const s = String(value ?? "");
if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
export function descriptorsFromColumns(columns) {
return (columns || [])
.map((col) => {
const id = col.id ?? col.accessorKey;
if (id == null || id === "") return null;
const header =
typeof col.header === "string"
? col.header
: typeof id === "string"
? id
: "Column";
return { id: String(id), header };
})
.filter(Boolean);
}
/**
* API rows may be sparse arrays (holes before real data). Array.from + filter
* yields a dense list so CSV rows align with the table.
*/
export function denseExportRows(rows) {
if (!Array.isArray(rows)) return [];
return Array.from(rows).filter(
(row) => row != null && typeof row === "object"
);
}
function cellToExportValue(value) {
if (value == null) return "";
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "boolean") return value;
if (value instanceof Date) return value;
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return value;
}
export function matrixFromData(data, descriptors) {
const keys = descriptors.map((d) => d.id);
const headerRow = descriptors.map((d) => d.header);
const body = [];
for (const row of denseExportRows(data)) {
body.push(keys.map((k) => cellToExportValue(row[k])));
}
return [headerRow, ...body];
}
export function matrixToCsv(matrix) {
return matrix
.map((line) => line.map(escapeCsvCell).join(","))
.join("\r\n");
}
function safeFileBase(fileBase) {
return String(fileBase || "export").replace(/[^\w\-]+/g, "-");
}
/** DD-MM-YYYY for export filenames (e.g. 26-05-2026). */
export function formatExportDateDdMmYyyy(date = new Date()) {
const d =
date instanceof Date && !Number.isNaN(date.getTime()) ? date : new Date();
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
return `${dd}-${mm}-${yyyy}`;
}
/** One path segment: strips parentheticals, then non-word chars → hyphens. */
export function sanitizeExportFileSegment(value) {
return String(value ?? "")
.trim()
.replace(/\s*\([^)]*\)\s*/g, "")
.replace(/[^\w\-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
}
export function tabNameForLeaderboardExport(activeTab) {
const t = String(activeTab ?? "").trim();
if (t === "Leaderboard") return "Leaderboard";
if (t === "My Team") return "My-Team";
if (t === "Scoreboard") return "Scoreboard";
if (t === "Value") return "Value";
if (t === "Trend") return "Trend";
if (t === "Detail") return "Detail";
if (!t) return "Leaderboard";
const lower = t.toLowerCase().replace(/\s+/g, " ");
if (lower === "doctor list" || lower === "doctorlist") return "Doctor-List";
if (lower.includes("doctor") && lower.includes("list")) return "Doctor-List";
return sanitizeExportFileSegment(t) || "Export";
}
/**
* Tab segment for metric report / campaign exports.
* @param {string} activeTab
* @param {{ trendSubTab?: string, useDoctorList?: boolean }} [options]
*/
export function tabNameForMetricExport(activeTab, options = {}) {
const { trendSubTab, useDoctorList } = options;
if (useDoctorList) return "Doctor-List";
if (String(activeTab ?? "").trim() === "Trend" && trendSubTab) {
if (trendSubTab === "tabular") return "Trend-Tabular";
if (trendSubTab === "line") return "Trend-Line";
if (trendSubTab === "bar") return "Trend-Bar";
return (
sanitizeExportFileSegment(`Trend-${trendSubTab}`) ||
tabNameForLeaderboardExport("Trend")
);
}
return tabNameForLeaderboardExport(activeTab);
}
/**
* {Metric}-{Tab}-{DD-MM-YYYY} — metric title only, no extra prefix.
* e.g. "Report Rx" + My Team → Report-Rx-My-Team-26-05-2026
*/
export function buildMetricExportFileBase({
metricName = "",
tabName = "Leaderboard",
trendSubTab,
useDoctorList = false,
date = new Date(),
} = {}) {
const parts = [
sanitizeExportFileSegment(metricName) || "Metric",
tabNameForMetricExport(tabName, { trendSubTab, useDoctorList }),
formatExportDateDdMmYyyy(date),
];
return parts.filter(Boolean).join("-");
}
/** @deprecated Use buildMetricExportFileBase */
export const buildLeaderboardExportFileBase = buildMetricExportFileBase;
export async function exportMatrixCsv(matrix, fileBase) {
if (!matrix?.length || matrix.length <= 1) {
throw new Error("No rows to export");
}
const csv = `\ufeff${matrixToCsv(matrix)}`;
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
await saveBlobWithDownloadUI(blob, `${safeFileBase(fileBase)}.csv`);
}
/** Flat row objects + TanStack-style column defs → CSV download */
export async function exportRowsCsv(rows, columns, fileBase) {
const descriptors = descriptorsFromColumns(columns);
const matrix = matrixFromData(rows, descriptors);
await exportMatrixCsv(matrix, fileBase);
}

View File

@ -0,0 +1,4 @@
export function toMetricNumber(v) {
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) ? n : 0;
}

View File

@ -0,0 +1,33 @@
/** Shared SVG layout for Trend line/bar charts (screen + PNG). */
export const METRIC_TREND_SVG = {
width: 348,
height: 188,
padL: 34,
padR: 28,
padT: 14,
padB: 24,
yLabelXOffset: 10,
};
/** Avoid clipping long x labels (e.g. "May 2026") at plot edges. */
export function trendXLabelAnchor(index, total) {
if (total <= 1) return "middle";
if (index === 0) return "start";
if (index === total - 1) return "end";
return "middle";
}
export function getTrendChartLayout() {
const { width, height, padL, padR, padT, padB } = METRIC_TREND_SVG;
return {
width,
height,
padL,
padR,
padT,
padB,
innerW: width - padL - padR,
innerH: height - padT - padB,
yLabelX: padL - METRIC_TREND_SVG.yLabelXOffset,
};
}

View File

@ -14,6 +14,10 @@ import {
FORM_APPROVED,
FORM_REJECT,
FORM_SUBMITTED,
CONTACT_APPROVAL,
CONTACT_APPROVED,
CONTACT_REJECT,
CONTACT_SUBMITTED,
handleNotificationClick,
TARGET_ACHIEVED,
} from "./notificationsUtil";
@ -29,6 +33,7 @@ function NotificationListCard(props) {
const [showOverlayNotificationModal, setShowOverlayNotificationModal] = useState(false);
const classes = props.className ? props.className : "";
const notificationId = props.data?.id;
const icon =
props.data?.icon !== "" ? props.data?.icon : "notifications-icon.png";
const iconBgColor = props.data?.bg_color
@ -158,10 +163,10 @@ function NotificationListCard(props) {
props.data?.payload?.type === FORM_APPROVED ||
props.data?.payload?.type === FORM_REJECT ||
props.data?.payload?.type === FORM_SUBMITTED ||
props.data?.payload?.type === TARGET_ACHIEVED
// ||
// props.data?.payload?.type === BROADCAST_CREATED ||
// props.data?.payload?.type === BROADCAST_LIKED
props.data?.payload?.type === TARGET_ACHIEVED ||
props.data?.payload?.type === CONTACT_APPROVED ||
props.data?.payload?.type === CONTACT_REJECT ||
props.data?.payload?.type === CONTACT_SUBMITTED
) {
console.log("Do nothing");
return;
@ -174,7 +179,7 @@ function NotificationListCard(props) {
<Button
type="button"
className={`btn-theme btn-sm btn-green stretched-link ${
props.data?.payload?.type !== FORM_APPROVAL && "opacity-0"
(props.data?.payload?.type === FORM_APPROVAL || props.data?.payload?.type === CONTACT_APPROVAL) ? "":"opacity-0"
}`}
// className={`btn-theme btn-sm btn-secondary-light stretched-link`}
// onClick={() => handleNotificationClick(props.data)}
@ -193,7 +198,7 @@ function NotificationListCard(props) {
// }}
onClick={handleButtonClick}
>
{props.data?.payload?.type === FORM_APPROVAL ? 'Approve / Reject' : 'Details'}
{props.data?.payload?.type === FORM_APPROVAL || props.data?.payload?.type === CONTACT_APPROVAL ? 'Approve / Reject' : 'Details'}
</Button>
);
@ -205,6 +210,8 @@ function NotificationListCard(props) {
>
{/* {props?.data?.id} */}
<Card.Body className={`px-0 py-2`}>
{/* <span className="text-start font-13 d-block">{notificationId}</span> */}
{/* <span>{props?.data?.notification_type}</span> */}
<div className="d-flex align-items-start">
<div
className={`flex-shrink-0 media-icon-wrapper d-flex align-items-center justify-content-center position-relative`}

View File

@ -21,9 +21,9 @@ function OverlayNotificationModal({show, handleClose, data}) {
};
const fixedHtml = fixHtml(data?.html_format || "");
const clean = DOMPurify.sanitize(fixedHtml);
console.log("data - ",data)
console.log("fixedHtml - ",fixedHtml)
console.log("clean - ",clean)
// console.log("data - ",data)
// console.log("fixedHtml - ",fixedHtml)
// console.log("clean - ",clean)
function getContentType (data) {
// console.log(data)

View File

@ -5,6 +5,10 @@ const FORM_APPROVAL = "form_approval";
const FORM_APPROVED = "form_approved";
const FORM_REJECT = "form_reject";
const FORM_SUBMITTED = "form_submitted";
const CONTACT_APPROVAL = "contact_approval";
const CONTACT_APPROVED = "contact_approved";
const CONTACT_REJECT = "contact_reject";
const CONTACT_SUBMITTED = "contact_submitted";
const BROADCAST_CREATED = "broadcast_created";
const BROADCAST_LIKED = "broadcast_liked";
const BROADCAST_COMMENT = "broadcast_comment";
@ -30,6 +34,9 @@ const handleNotificationClick = (payload) => {
: "";
let url = "/dashboard";
switch (payloadType) {
case "contact_approval":
url = `/approve-reject-contact-submission/${id}`;
break;
case "form_approval":
// let formInfo = { entity_id: payload?.entity_id };
// let res = getFormApprovalInfo(formInfo);
@ -41,7 +48,10 @@ const handleNotificationClick = (payload) => {
case "form_approved":
case "form_reject":
case "form_submitted":
case "target_achieved":
case "target_achieved":
case "contact_approved":
case "contact_reject":
case "contact_submitted":
console.log("Nothing to do...");
break;
case "broadcast_created":
@ -110,6 +120,10 @@ export {
FORM_APPROVED,
FORM_REJECT,
FORM_SUBMITTED,
CONTACT_APPROVAL,
CONTACT_APPROVED,
CONTACT_REJECT,
CONTACT_SUBMITTED,
MAGAZINE_CREATED,
MAGAZINE_MEDIA_CREATED,
BROADCAST_CREATED,

View File

@ -4,6 +4,7 @@ import { useLocation } from "react-router-dom";
function PageComponent(props) {
const location = useLocation();
const classes = props.className ? props.className : "";
const styles = props.styles ? props.styles : {};
useEffect(() => {
// console.log("From PageComponent scrooling...", location);
@ -11,7 +12,7 @@ function PageComponent(props) {
}, [location]);
return (
<div className={`page-content-wrapper ${classes}`}>{props.children}</div>
<div className={`page-content-wrapper ${classes}`} style={styles}>{props.children}</div>
);
}

View File

@ -110,6 +110,29 @@ function PlaceholderCard(props) {
</Card>
);
break;
case "metric-report-list":
card = (
<Card
className={`placeholder-card border-0 shadow-sm mb2 text-left overflow-hidden ${classes}`}
>
<Card.Body className="p2 text-center">
<div className="w-100 mb-2">
<Placeholder animation="glow">
<div>
<Placeholder xs={7} bg="secondary" />
</div>
<Placeholder xs={4} bg="secondary" />
</Placeholder>
</div>
<div className="w-100 mb-1">
<Placeholder animation="glow">
<Placeholder style={{ height: "100px", width: "100px", borderRadius: "14px" }} bg="secondary" />
</Placeholder>
</div>
</Card.Body>
</Card>
);
break;
case "banners":
card = (
<Card

View File

@ -0,0 +1,46 @@
.approve-reject-contact-modal .modal-content {
border-radius: 14px;
}
.approve-reject-contact-modal .modal-header {
height: 40px;
}
/* .btn-close */
.icon-wrapper {
background-color: var(--color-green);
color: #fff;
width: 80px;
height: 80px;
position: relative;
}
.icon-wrapper::before {
content: "";
width: 100%;
height: 100%;
border-radius: 50%;
position: absolute;
left: 0;
top: 0;
margin: auto;
/* -webkit-animation: ripple-fail 1s linear infinite 0.2s;
-o-animation: ripple-fail 1s linear infinite 0.2s; */
animation: ripple-success 1s linear infinite 0.2s;
}
.icon-wrapper.wrapper-fail {
background-color: var(--color-red);
}
.icon-wrapper.wrapper-fail::before {
animation: ripple-fail 1s linear infinite 0.2s;
}
/* .heading-icon::before {
content: "";
width: 100%;
height: 100%;
border-radius: 50%;
position: absolute;
left: 0;
top: 0;
margin: auto;
animation: ripple-success 1s linear infinite 0.2s;
} */

View File

@ -0,0 +1,132 @@
import React, { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Card, Modal, Placeholder, Button, Spinner } from "react-bootstrap";
import CustomIcon from "../../CustomIcon/CustomIcon";
import "./ApproveRejectContactModal.css";
import RejectContactReasonForm from "./RejectContactReasonForm";
import ContactSubmissionMessage from "./ContactSubmissionMessage";
import contactServices from "../../../services/ContactServices";
function ApproveRejectContactModal(props) {
const navigate = useNavigate();
// const [action, setAction] = useState(props.show.action);
const [isRejected, setIsRejected] = useState(false);
const [isApproved, setIsApproved] = useState(false);
const [loadingContactSubmission, setLoadingContactSubmission] = useState(true);
const modalExtraProps = {
backdrop: "static",
keyboard: false,
};
const allowHeaderCloseBtn = {
closeButton: props.action === "reject" && !isRejected ? true : false,
};
function btnClickHandler() {
console.log("btnClickHandler called...");
// props.handleClose();
// navigate('/notifications');
}
console.log(props.loading);
console.log(props.action);
useEffect(() => {
console.log(props.action);
if (props.action === "approve" && !isApproved) {
let approveData = {
submission_id: props?.approvalId,
action: props?.action,
};
// setAction(props.show.action);
console.log("approveData =", approveData);
approveContact(approveData);
}
if (props.action === "reject" && !isRejected) {
let rejectData = {
submission_id: props?.approvalId,
action: props?.action,
reason: "",
};
// setAction(props.show.action);
console.log("rejectData =", rejectData);
}
}, [props.action]);
//Approve Form submission
async function approveContact(data) {
console.log("Inside Modal: approveContact()...");
setLoadingContactSubmission(true);
contactServices
.approveContact(data)
.then((res) => {
console.log("approveContact RES = ", res.data);
if (res.data.status === "success") {
console.log("Success in approveContact =", res.data);
// setFormApprovalInfo(res.data?.data?.preview);
setIsApproved(true);
setLoadingContactSubmission(false);
}
})
.catch((error) => {
setLoadingContactSubmission(false);
setIsApproved(false);
// setFormApprovalInfo(null);
console.log("Error in approveContact = ", error);
});
}
return (
<Modal
centered
size="md"
// scrollable
className="theme-modal approve-reject-contact-modal"
{...modalExtraProps}
show={props.show.isVisible}
onHide={props.handleClose}
>
<Modal.Header className="pb-0 border-0 pt-2" {...allowHeaderCloseBtn}>
&nbsp;
</Modal.Header>
<Modal.Body className="no-scrollbar text-center pt-0">
{props.action === "approve" && !isApproved && (
<div className="p-5 d-flex justify-content-center align-items-center mx-auto">
<Spinner animation="border" />
</div>
)}
{props.action === "approve" && isApproved && (
<>
<ContactSubmissionMessage
type="approve"
text="Thanks!"
subText="You have successfully approved the contact."
actionHandler={props.handleClose}
className="pt-3"
/>
</>
)}
{props.action === "reject" && !isRejected && (
<RejectContactReasonForm
action={props.action}
isRejected={isRejected}
setIsRejected={setIsRejected}
approvalId={props?.approvalId}
/>
)}
{props.action === "reject" && isRejected && (
<>
<ContactSubmissionMessage
type="reject"
text=""
subText="You have rejected the contact. We Have notified the submitter about the rejection."
actionHandler={props.handleClose}
className="pt-3"
/>
</>
)}
</Modal.Body>
</Modal>
);
}
export default ApproveRejectContactModal;

View File

@ -0,0 +1,48 @@
import React from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "react-bootstrap";
import CustomIcon from "../../CustomIcon/CustomIcon";
function ContactSubmissionMessage({
type,
text,
subText,
actionHandler,
className,
}) {
const navigate = useNavigate();
const classes = className ? className : "";
function btnClickHandler() {
console.log("btnClickHandler");
actionHandler();
navigate("/notifications");
}
return (
<div className={`${classes}`}>
<div
className={`icon-wrapper ${
type === "reject" ? "wrapper-fail" : ""
} d-flex justify-content-center align-items-center rounded-circle mx-auto`}
>
<CustomIcon
tag={`${type === "reject" ? "delete7" : "check1"}`}
className="font-40"
/>
</div>
<div className="py-4 px-5 text-center font-15 text-gray-6">
<div className="fw-sb font-16 mb-1">{text}</div>
{subText}
</div>
<div className="d-grid gap-2">
<Button
className="btn-theme btn-primary btn-lg"
onClick={btnClickHandler}
>
Go Back
</Button>
</div>
</div>
);
}
export default ContactSubmissionMessage;

View File

@ -0,0 +1,146 @@
import React, { useState, useEffect, useContext } from "react";
import { Form, Button, Spinner, Row, Col, Card } from "react-bootstrap";
// import CustomCard from "../../../ui/CustomCard/CustomCard";
import { Field, Formik } from "formik";
import * as yup from "yup";
import contactServices from "../../../services/ContactServices";
import FormSubmissionMessage from "./ContactSubmissionMessage";
function RejectContactReasonForm(props) {
const [loading, setLoading] = useState(false);
const initialValues = {
reason: "",
};
const rejectReasonFormSchema = yup.object({
reason: yup.string().required("Required"),
});
//Reject Form submission
async function rejectContact(data) {
// console.log("Inside Dashboard: rejectForm()...");
setLoadingFormSubmission(true);
contactServices
.rejectContact(data)
.then((res) => {
console.log("rejectContact RES = ", res.data);
if (res.data.status === "success") {
console.log("Success in rejectContact =", res.data);
// setFormApprovalInfo(res.data?.data?.preview);
setLoadingFormSubmission(false);
}
})
.catch((error) => {
setLoadingFormSubmission(false);
// setFormApprovalInfo(null);
console.log("Error in rejectContact = ", error);
});
}
const onSubmit = async (values, { resetForm }) => {
setLoading(true);
console.log("Form Values :", values);
let newValues = {
...values,
submission_id: props.approvalId,
action: props.action,
};
console.log("Calling rejectReasonForm...", newValues);
contactServices
.rejectContact(newValues)
.then((res) => {
console.log("rejectContact RES = ", res.data);
if (res.data.status === "success") {
console.log("Success in rejectContact =", res.data);
// setFormApprovalInfo(res.data?.data?.preview);
setLoading(false);
props.setIsRejected(true);
}
})
.catch((error) => {
setLoading(false);
// setIsRejected()
// setFormApprovalInfo(null);
props.setIsRejected(false);
console.log("Error in rejectContact = ", error);
});
};
return (
<>
<div className="d-flex flex-column">
<Formik
initialValues={initialValues}
validationSchema={rejectReasonFormSchema}
onSubmit={onSubmit}
>
{(formik) => {
return (
<Form className="form_style" onSubmit={formik.handleSubmit}>
<Form.Group className="form-group rounded_corners mb-4">
<Form.Label className="d-block font-14 text-start">
Reason to Reject
</Form.Label>
<div className="pt-0 pb-3 text-gray6 font-12 text-start">
Your reason to reject will help the submitter to improve and
submit again.
</div>
<Form.Control
as="textarea"
rows={3}
style={{
height: "auto",
resize: "auto",
// minHeight: "40px",
maxHeight: "100px",
}}
placeholder="Reason"
name="reason"
{...formik.getFieldProps("reason")}
autoComplete="off"
/>
{formik.touched.reason && formik.errors.reason ? (
<p className="error-state-text text-start font-12 text-danger mb-0">
{formik.errors.reason}
</p>
) : null}
</Form.Group>
<div className="btn-wrap d-grid">
<Button
type="submit"
className="btn btn-theme btn-lg btn-red"
>
{loading ? (
<>
<Spinner
as="span"
animation="border"
size="sm"
role="status"
aria-hidden="true"
/>
</>
) : (
"Submit"
)}
</Button>
</div>
</Form>
);
}}
</Formik>
</div>
{/* <FormSubmissionMessage
type={props.action}
text=""
subText="You have rejected the submission. We Have notified the submitter about the rejection."
/> */}
</>
);
}
export default RejectContactReasonForm;

View File

@ -29,6 +29,30 @@ export const notificationTypes = [
color: "#ca8a04",
bgColor: "#f9edd1",
},
{
type: "contact_approval",
icon: "consult12",
color: "#1099bb",
bgColor: "#d9f7ff",
},
{
type: "contact_approved",
icon: "consult12",
color: "#1099bb",
bgColor: "#d9f7ff",
},
{
type: "contact_reject",
icon: "consult12",
color: "#1099bb",
bgColor: "#d9f7ff",
},
{
type: "contact_submitted",
icon: "consult12",
color: "#1099bb",
bgColor: "#d9f7ff",
},
{
type: "broadcast_created",
icon: "invitation2",

View File

@ -770,7 +770,7 @@ body {
.page-content-wrapper {
/* padding-top: 110px; */ /* For Browser view */
padding-top: 70px;
padding-top: 60px;
padding-bottom: 100px;
min-height: 100vh;
}

View File

@ -0,0 +1,267 @@
import React, { useState, useEffect } from "react";
import { useNavigate, useLocation, useParams } from "react-router-dom";
import {
Container,
Row,
Col,
Placeholder,
Button,
Ratio,
Card
} from "react-bootstrap";
import PageComponent from "../components/PageComponent/PageComponent";
import contactServices from "../services/ContactServices";
import ApproveRejectContactModal from "../components/UI/ApproveRejectContactModal/ApproveRejectContactModal";
import PlaceholderCard from "../components/PlaceholderCard/PlaceholderCard";
import CustomIcon from "../components/CustomIcon/CustomIcon";
import SectionHeading from "../components/UI/SectionHeading/SectionHeading";
import { handleAlert } from "../util/util";
function ApproveRejectContactSubmission() {
const { entityId } = useParams();
const [contactApprovalInfo, setContactApprovalInfo] = useState(null);
const [pageError, setPageError] = useState("");
const [action, setAction] = useState("");
const [loadingContactApprovalInfo, setLoadingContactApprovalInfo] = useState(true);
const [loadingIframe, setLoadingIframe] = useState(true);
const [loadingContactSubmission, setLoadingContactSubmission] = useState(true);
const [showContactSubmissionModal, setShowContactSubmissionModal] = useState({
isVisible: false,
// action: "",
});
useEffect(() => {
console.log("id = ", entityId);
if (entityId) {
console.log("id found = ", entityId);
let formInfo = { entity_id: entityId };
getContactApprovalInfo(formInfo);
} else {
console.log("id = ", entityId);
console.log("Id not found");
}
}, []);
//Contact Submission Modal close
const handleCloseContactSubmissionModal = () => {
console.log("Closing Contact Submission modal");
setShowContactSubmissionModal({
isVisible: false,
// action: "",
});
};
//Contact Submission Modal Open
const handleShowContactSubmissionModal = (actionType) => {
setShowContactSubmissionModal({
isVisible: true,
// action: action,
});
setAction(actionType);
// console.log("Opening the modal");
};
const { state } = useLocation();
// const formName = state.previewData?.form_name
// ? state.previewData?.form_name
// : "";
// const submittedBy = state.previewData?.submitted_by
// ? state.previewData?.submitted_by
// : "";
// const approvalId = state.previewData?.approval_id
// ? state.previewData?.approval_id
// : "";
// const previewUrl = state.previewData?.preview_url
// ? state.previewData?.preview_url
// : "";
// console.log(
// "Data passed via navigate to ApproveRejectFormSubmission = ",
// state
// );
//Get Form Approval Info
async function getContactApprovalInfo(data) {
console.log("Inside ApproveRejectFormSubmission: getContactApprovalInfo()...");
contactServices
.getContactApprovalInfo(data)
.then((res) => {
console.log("getContactApprovalInfo RES = ", res.data);
if (res.data.status === "success") {
console.log(
"Success in getContactApprovalInfo =",
res.data?.data?.preview
);
setContactApprovalInfo(res.data?.data?.preview);
setLoadingContactApprovalInfo(false);
setPageError("");
// handleAlert("Redirecting to Form submission page...", "success");
// navigate("/approve-reject-form-submission", {
// state: { previewData: res.data?.data?.preview },
// });
// setLoadingNotifications(false);
}
if (res.data.status === "failed") {
setLoadingContactApprovalInfo(false);
console.log("Inside es.data.status === 'failed'");
if(res?.data?.statusCode === 401) {
setPageError("You dont have permissions!");
} else {
setPageError("Something went wrong!");
}
}
})
.catch((error) => {
// setLoadingNotifications(false);
handleAlert("something went wrong!", "error");
setPageError("Something went wrong!");
setContactApprovalInfo(null);
setLoadingContactApprovalInfo(false);
console.log("Error in getFormApprovalInfo = ", error);
});
}
//Approve Form submission
async function approveContact(data) {
// console.log("Inside Dashboard: approveContact()...");
setLoadingContactSubmission(true);
contactServices
.approveContact(data)
.then((res) => {
console.log("approveContact RES = ", res.data);
if (res.data.status === "success") {
console.log("Success in approveContact =", res.data);
// setFormApprovalInfo(res.data?.data?.preview);
setLoadingContactSubmission(false);
}
})
.catch((error) => {
setLoadingContactSubmission(false);
// setFormApprovalInfo(null);
console.log("Error in approveContact = ", error);
});
}
//Reject Form submission
async function rejectContact(data) {
setLoadingContactSubmission(true);
contactServices
.rejectContact(data)
.then((res) => {
console.log("rejectContact RES = ", res.data);
if (res.data.status === "success") {
console.log("Success in rejectContact =", res.data);
// setFormApprovalInfo(res.data?.data?.preview);
setLoadingContactSubmission(false);
}
})
.catch((error) => {
setLoadingContactSubmission(false);
// setFormApprovalInfo(null);
console.log("Error in rejectContact = ", error);
});
}
function handleApproveBtnClick(data) {
console.log("handleApproveBtnClick called...", data);
handleShowContactSubmissionModal("approve");
// approveForm(data);
}
function handleRejectBtnClick(data) {
console.log("handleRejectBtnClick called...", data);
handleShowContactSubmissionModal("reject");
}
return (
<>
<PageComponent className="">
{/* <Container className="mb-4 min-vh-100 d-flex align-items-stretch"> */}
<Container className="">
{loadingContactApprovalInfo ? (
<>
<PlaceholderCard type="iframe-loader" />
</>
) : (
<>{pageError === "" ? (
<Ratio
aspectRatio="1x1"
className=""
style={{ height: `calc(100vh - 30vh)` }}
>
<iframe
src={`${contactApprovalInfo?.preview_url}`}
title="Approve or Reject Submission"
onLoad={() => setLoadingIframe(false)}
// sandbox="allow-scripts"
/>
</Ratio>
) : (
<Card className="card-theme border-0 shadow-sm p-3 rounded-3 font-14">
<Card.Body className="text-center">
<CustomIcon tag="warning2" className="font-40 text-theme-warning mb-3"/>
<SectionHeading size="" className="text-gray6" >{pageError}</SectionHeading>
</Card.Body>
</Card>
)}
</>
)}
{pageError === "" && (
<div className="footer-btn-overlay d-flex flex-column">
<div className="d-flex justify-content-center pt-2 pb-3">
<div className="font-14 text-theme-primary">Submitted By: </div>
<div className="font-14 fw-sb ps-2">
{contactApprovalInfo?.submitted_by}
</div>
</div>
<div className="btn-wrap d-flex justify-content-center align-items-center">
<Button
type="button"
className="btn-theme btn-md btn-green flex-grow-1"
onClick={() => {
let data = {
// approval_id: approvalId,
approval_id: contactApprovalInfo?.approval_id,
action: "approve",
};
handleApproveBtnClick(data);
}}
>
Approve
</Button>
<Button
type="button"
className="btn-theme btn-md btn-red flex-grow-1"
onClick={() => {
let data = {
// approval_id: approvalId,
approval_id: contactApprovalInfo?.approval_id,
action: "reject",
reason: "Testing reason",
};
handleRejectBtnClick(data);
}}
>
Reject
</Button>
</div>
</div>
)}
<ApproveRejectContactModal
show={showContactSubmissionModal}
handleClose={handleCloseContactSubmissionModal}
action={action}
// approveForm={approveForm}
// rejectForm={rejectForm}
approvalId={contactApprovalInfo?.approval_id}
// loading={loadingFormSubmission}
/>
</Container>
</PageComponent>
</>
);
}
export default ApproveRejectContactSubmission;

View File

@ -0,0 +1,6 @@
.campaign-details-page .campaign-period-select {
width: auto;
max-width: 100%;
font-size: 0.75rem;
background-position: right 0.35rem center;
}

View File

@ -1,91 +1,144 @@
import React, { useEffect, useState } from "react";
import { Container, Card, Form } from "react-bootstrap";
import { useLocation, useNavigate } from "react-router-dom";
import React, { useEffect, useMemo, useState } from "react";
import { Container, Card, Form, Spinner } from "react-bootstrap";
import { useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import PageComponent from "../components/PageComponent/PageComponent";
import CampaignSummaryCard from "../components/DashboardCampaigns/CampaignSummaryCard";
import SectionHeading from "../components/UI/SectionHeading/SectionHeading";
import dashboardService from "../services/Dashboard";
import {
DEFAULT_CAMPAIGN_PERIODS,
dedupePeriodOptions,
parseCampaignDetailsResponse,
sumMetricPoints,
} from "../util/campaignHelpers";
import "../components/DashboardCampaigns/DashboardCampaignsCard.css";
import "./CampaignDetails.css";
const DEFAULT_PERIODS = [
{ label: "May Month", value: "month-0" },
{ label: "April Month", value: "month-1" },
{ label: "Last 3 Months", value: "month-3" },
{ label: "Overall", value: "all" },
{ label: "This week", value: "week-0" },
{ label: "Last week", value: "week-1" },
{ label: "Today", value: "day-0" },
{ label: "Yesterday", value: "day-1" },
{ label: "Last 7 Days", value: "day-7" },
{ label: "Last 30 Days", value: "day-30" },
];
const DEFAULT_BG =
"https://png.pngtree.com/background/20250212/original/pngtree-a-photo-of-rainbow-watercolor-background-with-smoky-texture-picture-image_15774631.jpg";
function CampaignDetails() {
const location = useLocation();
const { id: campaignId } = useParams();
const navigate = useNavigate();
const campaign = location?.state?.campaign;
const campaignId = campaign?.campaignId || campaign?.campaign_id || campaign?.id;
const [selectedPeriod, setSelectedPeriod] = useState("month-0");
const [selectedPeriod, setSelectedPeriod] = useState(null);
const [appliedPeriod, setAppliedPeriod] = useState(null);
const { data: detailsRes, isFetching, isError } = useQuery({
queryKey: ["campaignDetails", campaignId, selectedPeriod],
useEffect(() => {
setSelectedPeriod(null);
setAppliedPeriod(null);
}, [campaignId]);
const { data: detailsRes, isFetching, isError, isSuccess } = useQuery({
queryKey: ["campaignDetails", campaignId, appliedPeriod],
enabled: !!campaignId,
placeholderData: (previous) => previous,
queryFn: async () => {
const res = await dashboardService.getCampaignDetails(campaignId, selectedPeriod);
const res = await dashboardService.getCampaignDetails(
campaignId,
appliedPeriod
);
return res?.data;
},
});
const periods = Array.isArray(detailsRes?.data?.periods)
? detailsRes.data.periods
: Array.isArray(detailsRes?.periods)
? detailsRes.periods
: [];
const periodOptions = periods.length ? periods : DEFAULT_PERIODS;
const { campaign: campaignInfo, metrics, periods } = useMemo(
() => parseCampaignDetailsResponse(detailsRes),
[detailsRes]
);
const campaignForCard = useMemo(() => {
if (!campaignInfo || !Object.keys(campaignInfo).length) return campaignInfo;
const totalFromApi = campaignInfo.total_points ?? campaignInfo.totalPoints;
if (totalFromApi != null && totalFromApi !== "") return campaignInfo;
const summed = sumMetricPoints(metrics);
if (summed == null) return campaignInfo;
return { ...campaignInfo, total_points: summed };
}, [campaignInfo, metrics]);
const periodOptions = useMemo(
() => dedupePeriodOptions(periods, DEFAULT_CAMPAIGN_PERIODS),
[periods]
);
const defaultPeriodValue =
campaignInfo?.default_period ?? campaignInfo?.defaultPeriod ?? null;
useEffect(() => {
if (!periodOptions.length) return;
if (selectedPeriod == null) {
const initial =
(defaultPeriodValue &&
periodOptions.find((p) => p?.value === defaultPeriodValue)?.value) ||
periodOptions[0]?.value ||
null;
setSelectedPeriod(initial);
return;
}
if (periodOptions.some((p) => p?.value === selectedPeriod)) return;
const first = periodOptions[0]?.value;
if (first) setSelectedPeriod(first);
}, [periodOptions, selectedPeriod]);
setSelectedPeriod(periodOptions[0]?.value ?? null);
}, [periodOptions, selectedPeriod, defaultPeriodValue]);
const screenTitle = detailsRes?.data?.title || detailsRes?.title || "Campaign";
useEffect(() => {
const currentTitle = location?.state?.pageTitle;
if (!screenTitle || currentTitle === screenTitle) return;
navigate(location.pathname, {
replace: true,
state: { ...(location.state || {}), pageTitle: screenTitle },
});
}, [navigate, location.pathname, location.state?.pageTitle, screenTitle]);
const handlePeriodChange = (e) => {
const value = e.target.value;
setSelectedPeriod(value);
setAppliedPeriod(value);
};
const cards = Array.isArray(detailsRes?.data)
? detailsRes.data
: Array.isArray(detailsRes?.data?.metrics)
? detailsRes.data.metrics
: detailsRes?.data?.items || detailsRes?.data?.campaigns || [];
const bgImage = campaignInfo?.bg_image || campaignInfo?.bgImage;
const pageBGStyles = {
backgroundImage: `url(${bgImage?.trim() ? bgImage : DEFAULT_BG})`,
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
backgroundPosition: "center center",
};
const openLeaderboard = (rankType) => {
if (!campaignId) return;
navigate(
`/campaigns/${encodeURIComponent(campaignId)}/leaderboard?rank_type=${encodeURIComponent(rankType)}`,
{ state: { pageTitle: "Campaign Leaderboard" } }
);
};
const showCampaignCard =
!!campaignId && !isError && (isSuccess || Object.keys(campaignInfo).length > 0);
return (
<PageComponent className="metric-report-page">
<div className="metric-report-shell">
<Container fluid className="metric-inner px-3 pt-3 pb-5">
<div className="pt-3">
<div className="d-flex align-items-center justify-content-end">
<Form.Select
size="sm"
className="metric-period-select"
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
>
{periodOptions.map((p, idx) => (
<option key={`${p?.value}-${idx}`} value={p?.value}>
{p?.label}
</option>
))}
</Form.Select>
</div>
<PageComponent className="campaign-details-page" styles={pageBGStyles}>
<div className="metric-report-shell bg-transparent">
<Container fluid className="metric-inner pt-3 pb-5">
<div className="d-flex justify-content-end mb-2">
<Form.Select
size=""
className="campaign-period-select"
value={selectedPeriod ?? periodOptions[0]?.value ?? ""}
onChange={handlePeriodChange}
disabled={isFetching || !periodOptions.length}
>
{periodOptions.map((p, idx) => (
<option key={`${p?.value}-${idx}`} value={p?.value}>
{p?.label}
</option>
))}
</Form.Select>
</div>
<div className="p-3">
{showCampaignCard ? (
<CampaignSummaryCard
campaign={campaignForCard}
className="mt-0"
onRankClick={openLeaderboard}
/>
) : isFetching ? (
<div className="text-center text-muted small py-3">
<Spinner animation="border" variant="secondary" size="sm" />{" "}
<span className="ms-1 font-14">Loading campaign…</span>
</div>
) : null}
<div className="py-3">
{!campaignId ? (
<div className="text-center text-muted small py-4">
Missing campaign id.
@ -94,16 +147,17 @@ function CampaignDetails() {
<div className="text-center text-danger small py-4">
Failed to load campaign details.
</div>
) : isFetching ? (
) : isFetching && !metrics.length ? (
<div className="text-center text-muted small py-4">
Loading…
<Spinner animation="border" variant="secondary" size="sm" />{" "}
<span className="ms-1 font-14">Loading…</span>
</div>
) : cards.length === 0 ? (
) : metrics.length === 0 ? (
<div className="text-center text-muted small py-4">No data.</div>
) : (
<div className="d-flex flex-column gap-3">
{cards.map((c, idx) => {
const name = c?.name || c?.title || `Campaign ${idx + 1}`;
{metrics.map((c, idx) => {
const name = c?.name || c?.title || `Metric ${idx + 1}`;
const value = c?.value ?? c?.achieved ?? c?.achieved_value ?? 0;
const units =
c?.units ??
@ -116,7 +170,8 @@ function CampaignDetails() {
const points =
c?.points ??
c?.score ??
(Number.isFinite(Number(value)) && Number.isFinite(Number(weight))
(Number.isFinite(Number(value)) &&
Number.isFinite(Number(weight))
? Number(value) * Number(weight)
: 0);
const valueColor =
@ -126,27 +181,46 @@ function CampaignDetails() {
: "text-danger";
return (
<Card key={idx} className="border-0 shadow-sm overflow-hidden">
<div className="bg-light py-2 px-3 border-bottom">
<div className="text-center fw-semibold">{name}</div>
<Card
key={idx}
className="card-theme border-0 shadow-sm overflow-hidden"
style={{
background: "rgba(255,255,255,0.45)",
backdropFilter: "blur(15px)",
}}
>
<div className="bg-ligh py-2 pb-1 px-3 border-botto">
<SectionHeading
size=""
className="mb-1 fw-b text-center font-14"
>
{name}
</SectionHeading>
</div>
<div className="d-flex">
<div className="flex-fill py-4 px-3 text-center border-end">
<div className="d-flex py-2 pt-0">
<div className="flex-fill py-1 px-2 text-center border-en" style={{borderRight: "1px solid rgba(0,0,0,0.1)"}}>
<div className={`fw-bold display-6 mb-1 ${valueColor}`}>
{value}
</div>
{units ? (
<div className="text-muted small">{units}</div>
<SectionHeading
size=""
className="mb-1 text-center font-12"
>
{units}
</SectionHeading>
) : null}
</div>
<div className="flex-fill py-4 px-3 text-center">
<div className="flex-fill py-1 px-2 text-center">
<div
className="fw-bold display-6 mb-1"
style={{ color: "#00BCD4" }}
style={{ color: "var(--color-green)" }}
>
{points}
</div>
<div className="text-muted small">Points</div>
<SectionHeading size="" className="mb-1 text-center font-12">
Points
</SectionHeading>
</div>
</div>
</Card>
@ -162,4 +236,3 @@ function CampaignDetails() {
}
export default CampaignDetails;

View File

@ -0,0 +1,32 @@
.campaign-leaderboard-page .metric-report-shell {
max-width: 28rem;
}
.campaign-leaderboard-page .dashboard-campaign-rank-hit {
cursor: pointer;
transition: opacity 0.15s ease;
}
.campaign-leaderboard-page .dashboard-campaign-rank-hit:active {
opacity: 0.85;
}
.campaign-leaderboard-heading {
border-bottom: 1px dashed var(--color-gray-light3);
}
.badge.campaign-leaderboard-rank-badge {
font-size: 12px;
padding: 0.4rem 0.5rem;
background-color: var(--color-yellow-lighter) !important;
color: var(--color-gray5) !important;
}
.campaign-leaderboard-page tr.metric-leaderboard-row--self td {
background-color: #fff3cd !important;
}
.campaign-leaderboard-page tr.metric-leaderboard-row--self td.metric-sticky-col-leader-1,
.campaign-leaderboard-page tr.metric-leaderboard-row--self td.metric-sticky-col-leader-2 {
background-color: #fff3cd !important;
}

View File

@ -0,0 +1,615 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Container, Card, Form, Spinner, Badge, Button } from "react-bootstrap";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import PageComponent from "../components/PageComponent/PageComponent";
import SectionHeading from "../components/UI/SectionHeading/SectionHeading";
import CustomIcon from "../components/CustomIcon/CustomIcon";
import MetricReportDataTable from "../components/MetricReports/MetricReportDataTable";
import LeaderboardExportModal from "../components/MetricReports/LeaderboardExportModal";
import {
buildLeaderboardAdditionalFieldOptions,
buildLeaderboardExportColumns,
applyRowMetricsToLeaderboardExportParsed,
buildMetricLeaderboardExportColumns,
getMetricLeaderboardExportFromApi,
LEADERBOARD_EXPORT_FORM_DEFAULT,
prepareLeaderboardExportRows,
prepareLeaderboardExportRowsFromRaw,
} from "../components/MetricReports/leaderboardExportUtil";
import {
filterLeaderboardColumnsByRowMetrics,
getLeaderboardTabularConfig,
leaderboardConfigToExportColumns,
mapLeaderboardRowsFromConfig,
normalizeLeaderboardTabularConfig,
} from "../components/MetricReports/leaderboardTabularUtil";
import {
exportTableAsPng,
LEADERBOARD_PNG_MAX_ROWS,
METRIC_CAMPAIGN_TABLE_EXPORT_LAYOUT_PX,
} from "../components/MetricReports/exportTableAsPng";
import {
buildMetricExportFileBase,
exportRowsCsv,
} from "../components/MetricReports/metricReportExportUtil";
import dashboardService from "../services/Dashboard";
import authService from "../services/Auth";
import { handleAlert } from "../util/util";
import {
DEFAULT_CAMPAIGN_PERIODS,
dedupePeriodOptions,
extractCampaignDateRange,
getCampaignRankTypeLabel,
parseCampaignDashboardResponse,
parseCampaignDetailsResponse,
} from "../util/campaignHelpers";
import "./MetricReports.css";
import "./CampaignLeaderboard.css";
function RankBadge({ rank }) {
const cls =
rank === 1
? "metric-rank-badge metric-rank-badge--gold"
: rank === 2
? "metric-rank-badge metric-rank-badge--silver"
: rank === 3
? "metric-rank-badge metric-rank-badge--bronze"
: "metric-rank-badge metric-rank-badge--muted";
return <span className={cls}>{rank}</span>;
}
function positionField(positions, ...keys) {
if (!positions || typeof positions !== "object") return "-";
for (const k of keys) {
const v = positions[k];
if (v != null && String(v).trim() !== "") return String(v);
}
return "-";
}
function enrichCampaignLeaderboardRow(rawRow, mappedRow) {
const u = rawRow?.user || {};
const employee_code =
mappedRow.user_employee_code ??
mappedRow.employee_code ??
u?.employee_code ??
"-";
return {
...mappedRow,
user_id: u?.id ?? u?.user_id ?? rawRow?.user_id ?? null,
employee_code,
};
}
/** Keep highlight metadata on flattened table export rows (PNG). */
function enrichCampaignTableExportRow(tableRow, exportRow) {
return {
...exportRow,
user_id: tableRow?.user_id ?? exportRow.user_id ?? null,
employee_code:
tableRow?.employee_code ??
exportRow.user_employee_code ??
exportRow.employee_code ??
"-",
};
}
function mapLeaderboardRows(response) {
if (!Array.isArray(response)) return [];
return Array.from(response)
.filter((row) => row != null && typeof row === "object")
.map((row) => {
const u = row?.user || {};
const positions = u?.positions || {};
const score =
typeof row?.score === "number"
? Math.round(row.score * 100) / 100
: row?.score ?? 0;
return {
rank: row?.rank ?? 0,
user_id: u?.id ?? u?.user_id ?? row?.user_id ?? null,
name: [u?.name, u?.designation ? ` (${u.designation})` : ""]
.filter(Boolean)
.join(""),
employee_code: u?.employee_code ?? "-",
zone: positionField(positions, "Zone", "zone"),
region: positionField(positions, "Region", "region"),
area: positionField(positions, "Area", "area"),
territory: positionField(positions, "Territory", "territory"),
score,
units: score,
};
})
.sort((a, b) => Number(a.rank) - Number(b.rank));
}
function CampaignLeaderboard() {
const { id: campaignId } = useParams();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const rankTypeParam = searchParams.get("rank_type") || "overallRank";
const [selectedPeriod, setSelectedPeriod] = useState(null);
const [appliedPeriod, setAppliedPeriod] = useState(null);
const [showExportModal, setShowExportModal] = useState(false);
const [exportForm, setExportForm] = useState({
...LEADERBOARD_EXPORT_FORM_DEFAULT,
});
useEffect(() => {
setSelectedPeriod(null);
setAppliedPeriod(null);
}, [campaignId, rankTypeParam]);
useEffect(() => {
if (!campaignId) {
navigate("/dashboard", { replace: true });
}
}, [campaignId, navigate]);
const requestPeriod = appliedPeriod ?? selectedPeriod;
const { data: periodsRes } = useQuery({
queryKey: ["campaignDetailsPeriods", campaignId, requestPeriod],
enabled: !!campaignId && !!requestPeriod,
queryFn: async () => {
const res = await dashboardService.getCampaignDetails(
campaignId,
requestPeriod
);
return res?.data;
},
});
const { periods: detailsPeriods } = useMemo(
() => parseCampaignDetailsResponse(periodsRes),
[periodsRes]
);
const { data: leaderboardRes, isFetching, isError } = useQuery({
queryKey: ["campaignLeaderboard", campaignId, rankTypeParam, requestPeriod],
enabled: !!campaignId && !!requestPeriod,
placeholderData: (previous) => previous,
queryFn: async () => {
const res = await dashboardService.getCampaignMetrics(
campaignId,
rankTypeParam,
requestPeriod
);
return res?.data;
},
});
const {
campaign: campaignInfo,
response: responseRows,
periods: dashboardPeriods,
firstDay,
lastDay,
} = useMemo(
() => parseCampaignDashboardResponse(leaderboardRes, requestPeriod),
[leaderboardRes, requestPeriod]
);
const periodOptions = useMemo(
() =>
dedupePeriodOptions(
dashboardPeriods?.length ? dashboardPeriods : detailsPeriods,
DEFAULT_CAMPAIGN_PERIODS
),
[dashboardPeriods, detailsPeriods]
);
const defaultPeriodValue =
campaignInfo?.default_period ?? campaignInfo?.defaultPeriod ?? null;
useEffect(() => {
if (!periodOptions.length) return;
if (selectedPeriod == null) {
const initial =
(defaultPeriodValue &&
periodOptions.find((p) => p?.value === defaultPeriodValue)?.value) ||
periodOptions[0]?.value ||
null;
setSelectedPeriod(initial);
if (initial != null && appliedPeriod == null) {
setAppliedPeriod(initial);
}
return;
}
if (periodOptions.some((p) => p?.value === selectedPeriod)) return;
const fallback = periodOptions[0]?.value ?? null;
setSelectedPeriod(fallback);
if (appliedPeriod == null && fallback != null) {
setAppliedPeriod(fallback);
}
}, [periodOptions, selectedPeriod, defaultPeriodValue, appliedPeriod]);
const handlePeriodChange = (e) => {
const value = e.target.value;
setSelectedPeriod(value);
setAppliedPeriod(value);
};
const campaignTitle =
campaignInfo?.title || campaignInfo?.name || "Campaign";
const apiRankType = campaignInfo?.rank_type ?? campaignInfo?.rankType;
const rankTypeBadgeLabel = getCampaignRankTypeLabel(
apiRankType || rankTypeParam
);
const dateRangeFromPeriodOption = useMemo(() => {
if (!requestPeriod) return { firstDay: null, lastDay: null };
const match = periodOptions.find((p) => p?.value === requestPeriod);
return extractCampaignDateRange(
match ? { periods: [match] } : {},
requestPeriod
);
}, [periodOptions, requestPeriod]);
const dateRangeFromDetails = useMemo(() => {
if (!periodsRes) return { firstDay: null, lastDay: null };
let body = periodsRes;
if (
body?.data != null &&
typeof body.data === "object" &&
!Array.isArray(body.data)
) {
body = body.data;
}
return extractCampaignDateRange(body, requestPeriod);
}, [periodsRes, requestPeriod]);
const resolvedFirstDay =
firstDay ?? dateRangeFromPeriodOption.firstDay ?? dateRangeFromDetails.firstDay;
const resolvedLastDay =
lastDay ?? dateRangeFromPeriodOption.lastDay ?? dateRangeFromDetails.lastDay;
const dateRangeLabel =
resolvedFirstDay && resolvedLastDay
? `${resolvedFirstDay} to ${resolvedLastDay}`
: isFetching || !requestPeriod
? "Fetching…"
: "—";
const authUser = useMemo(() => {
try {
const raw = authService.getAuthUser();
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}, []);
const leaderboardTabularColumns = useMemo(
() =>
filterLeaderboardColumnsByRowMetrics(
normalizeLeaderboardTabularConfig(
getLeaderboardTabularConfig(leaderboardRes)
),
responseRows
),
[leaderboardRes, responseRows]
);
const usesLeaderboardTabularConfig = leaderboardTabularColumns.length > 0;
const leaderboardExportParsed = useMemo(() => {
const parsed = getMetricLeaderboardExportFromApi(leaderboardRes);
if (!parsed.allColumns.length) return parsed;
return applyRowMetricsToLeaderboardExportParsed(parsed, responseRows);
}, [leaderboardRes, responseRows]);
const usesLeaderboardExportConfig =
leaderboardExportParsed.allColumns.length > 0;
const tableRows = useMemo(() => {
if (usesLeaderboardTabularConfig) {
return mapLeaderboardRowsFromConfig(
responseRows,
leaderboardTabularColumns,
{ enrichRow: enrichCampaignLeaderboardRow }
);
}
return mapLeaderboardRows(responseRows);
}, [
usesLeaderboardTabularConfig,
responseRows,
leaderboardTabularColumns,
]);
const campaignAdditionalFieldOptions = useMemo(() => {
if (usesLeaderboardExportConfig) {
return leaderboardExportParsed.additionalOptions;
}
if (usesLeaderboardTabularConfig) {
return [];
}
return buildLeaderboardAdditionalFieldOptions([], { includeRole: false });
}, [
usesLeaderboardExportConfig,
usesLeaderboardTabularConfig,
leaderboardExportParsed.additionalOptions,
]);
const defaultExportAdditionalFieldIds = useMemo(
() => campaignAdditionalFieldOptions.map((f) => f.id),
[campaignAdditionalFieldOptions]
);
const showExportPrevRef = useRef(false);
useEffect(() => {
const justOpened = showExportModal && !showExportPrevRef.current;
showExportPrevRef.current = showExportModal;
if (!justOpened || !campaignAdditionalFieldOptions.length) return;
setExportForm((prev) => ({
...prev,
additionalFieldIds: defaultExportAdditionalFieldIds,
}));
}, [
showExportModal,
defaultExportAdditionalFieldIds,
campaignAdditionalFieldOptions.length,
]);
const campaignConfigExportColumns = useMemo(
() => leaderboardConfigToExportColumns(leaderboardTabularColumns),
[leaderboardTabularColumns]
);
const isCurrentUserRow = useCallback(
(row) => {
if (!row || !authUser) return false;
const authId = authUser.id ?? authUser.user_id;
if (authId != null && row.user_id != null) {
return String(row.user_id) === String(authId);
}
const authCode = authUser.employee_code;
const rowCode = row.employee_code ?? row.user_employee_code;
if (
authCode != null &&
String(authCode).trim() !== "" &&
rowCode != null &&
rowCode !== "-"
) {
return String(rowCode) === String(authCode);
}
return false;
},
[authUser]
);
const tableColumns = useMemo(() => {
if (usesLeaderboardTabularConfig) {
return leaderboardTabularColumns.map((col, index) => {
const column = {
id: col.accessorKey,
accessorKey: col.accessorKey,
header: col.label,
};
if (index < 2) {
column.meta = { sticky: true };
}
if (col.isRank) {
column.cell = (info) => (
<RankBadge rank={Number(info.getValue()) || 0} />
);
}
return column;
});
}
return [
{
id: "rank",
accessorKey: "rank",
header: "Rank",
meta: { sticky: true },
cell: (info) => <RankBadge rank={Number(info.getValue()) || 0} />,
},
{ accessorKey: "name", header: "User", meta: { sticky: true } },
{ accessorKey: "employee_code", header: "Employee Code" },
{ accessorKey: "zone", header: "Zone" },
{ accessorKey: "region", header: "Region" },
{ accessorKey: "area", header: "Area" },
{ accessorKey: "territory", header: "Territory" },
{ accessorKey: "score", header: "Score" },
];
}, [usesLeaderboardTabularConfig, leaderboardTabularColumns]);
const exportFileBase = useMemo(
() =>
buildMetricExportFileBase({
metricName: campaignTitle,
tabName: "Leaderboard",
}),
[campaignTitle]
);
function openExportModal() {
setExportForm({
...LEADERBOARD_EXPORT_FORM_DEFAULT,
additionalFieldIds: defaultExportAdditionalFieldIds,
format: "csv",
});
setShowExportModal(true);
}
async function confirmCampaignExportDownload() {
if (!responseRows.length) {
handleAlert("No leaderboard data to export.", "info");
return;
}
let cols;
let exportRows;
if (usesLeaderboardExportConfig) {
const selectedAdditionalIds = (exportForm.additionalFieldIds || []).filter(
(id) =>
leaderboardExportParsed.additionalOptions.some((o) => o.id === id)
);
cols = buildMetricLeaderboardExportColumns(
selectedAdditionalIds,
leaderboardExportParsed
);
exportRows = prepareLeaderboardExportRowsFromRaw(
responseRows,
cols,
{
topRanks: exportForm.topRanks,
pngMaxRows: LEADERBOARD_PNG_MAX_ROWS,
format: exportForm.format,
enrichRow: enrichCampaignLeaderboardRow,
}
);
} else if (usesLeaderboardTabularConfig) {
cols = campaignConfigExportColumns;
exportRows = prepareLeaderboardExportRows(tableRows, cols, {
topRanks: exportForm.topRanks,
pngMaxRows: LEADERBOARD_PNG_MAX_ROWS,
format: exportForm.format,
enrichRow: enrichCampaignTableExportRow,
});
} else {
const selectedAdditionalIds = (exportForm.additionalFieldIds || []).filter(
(id) => campaignAdditionalFieldOptions.some((o) => o.id === id)
);
cols = buildLeaderboardExportColumns(selectedAdditionalIds, []);
exportRows = prepareLeaderboardExportRows(tableRows, cols, {
topRanks: exportForm.topRanks,
pngMaxRows: LEADERBOARD_PNG_MAX_ROWS,
format: exportForm.format,
enrichRow: enrichCampaignTableExportRow,
});
}
try {
if (exportForm.format === "png") {
await exportTableAsPng(exportRows, cols, exportFileBase, {
highlightRow: isCurrentUserRow,
maxLayoutWidth: METRIC_CAMPAIGN_TABLE_EXPORT_LAYOUT_PX,
outputScale: 2,
useLeaderboardTableStyles: true,
});
} else {
await exportRowsCsv(exportRows, cols, exportFileBase);
}
setShowExportModal(false);
} catch {
handleAlert("Export failed.", "info");
}
}
if (!campaignId) {
return null;
}
return (
<PageComponent className="metric-report-page campaign-leaderboard-page">
<div className="metric-report-shell">
<Container fluid className="metric-inner px- pt-1 pb-5">
{!isError && !isFetching ? (
<div className="campaign-leaderboard-heading d-flex px-0 pt-1 pb-1 mb-2">
<div className="mb-1 order-2 flex-shrink-0">
<Badge className="campaign-leaderboard-rank-badge fw-sb font-12">
{rankTypeBadgeLabel}
</Badge>
</div>
<SectionHeading
size=""
className="mb-1 fw-sb flex-grow-1 me-3 font-14"
>
{campaignTitle}
</SectionHeading>
</div>
) : null}
<div className="metric-period-header mb-1">
<div className="d-flex align-items-center">
<SectionHeading size="xs" className="mb-1 fw-n flex-grow-1">
<CustomIcon tag="date4" className="font-16 me-1" />
<span style={{ fontSize: "10px" }}>{dateRangeLabel}</span>
</SectionHeading>
<div className="flex-shrink-0 me-2" style={{ minWidth: 90 }}>
<Form.Select
size="sm"
className="metric-period-select"
value={selectedPeriod ?? periodOptions[0]?.value ?? ""}
onChange={handlePeriodChange}
disabled={isFetching || !periodOptions.length}
>
{periodOptions.length === 0 ? (
<option value="">Loading…</option>
) : (
periodOptions.map((p, idx) => (
<option key={`${p?.value}-${idx}`} value={p?.value}>
{p?.label}
</option>
))
)}
</Form.Select>
</div>
<div className="flex-shrink-0">
<Button
type="button"
onClick={openExportModal}
className="btn-theme btn-badges font-12 btn-download-report py-1 px-2 rounded-1"
aria-label="Download leaderboard"
disabled={isFetching || !tableRows.length}
>
<CustomIcon tag="download1" className="font-13" />
</Button>
</div>
</div>
</div>
<Card className="border-0 shadow-sm metric-content-card mt-2">
<Card.Body className="p-0">
{isError ? (
<div className="p-4 text-center text-danger small">
Failed to load leaderboard.
</div>
) : isFetching && !tableRows.length ? (
<div className="p-4 text-center text-muted small">
<Spinner animation="border" variant="secondary" size="sm" />{" "}
<span className="ms-1 font-14">Loading leaderboard…</span>
</div>
) : (
<div className="metric-table-wrap">
<MetricReportDataTable
columns={tableColumns}
data={tableRows}
stickyLeadingColumns={2}
getRowClassName={(row) =>
isCurrentUserRow(row) ? "metric-leaderboard-row--self" : ""
}
/>
</div>
)}
</Card.Body>
</Card>
</Container>
</div>
<LeaderboardExportModal
show={showExportModal}
onHide={() => setShowExportModal(false)}
title="Export Campaign Leaderboard"
showAdditionalFields={
usesLeaderboardExportConfig || !usesLeaderboardTabularConfig
}
alwaysIncludedHint={
usesLeaderboardExportConfig
? "Rank, User, Employee Code, and Units/Points are always included. Additional columns below are selected by default — uncheck any you do not need."
: undefined
}
additionalFieldOptions={campaignAdditionalFieldOptions}
exportForm={exportForm}
setExportForm={setExportForm}
onConfirm={confirmCampaignExportDownload}
/>
</PageComponent>
);
}
export default CampaignLeaderboard;

View File

@ -402,13 +402,11 @@ function Dashboard() {
<PerformanceStatsContainer />
<DashboardCampaignsContainer />
{otherBanners?.length > 0 && (
<section
className={`overflow-hidden ${
otherBanners.length > 0
? "py-3 px-4 py-sm-3 bg-red-lighter1"
? "py-3 px-4 py-sm-3 my-2 bg-red-lighter1"
: "d-none"
}`}
>
@ -423,6 +421,8 @@ function Dashboard() {
</section>
)}
<DashboardCampaignsContainer />
<DashboardCompetitionContestsContainer />
<DashboardMagazinesContainer />

View File

@ -22,16 +22,32 @@
max-width: 100%;
}
.metric-report-page .metric-details-heading {
border-bottom: 1px dashed var(--color-gray-light3);
}
.metric-report-page .metric-period-header {
position: relative;
border-bottom:1px dashed var(--color-gray-light3);
padding-bottom: 8px;
}
.metric-report-page .metric-menu-anchor-abs {
position: absolute;
position: fixed;
top: 50px;
/* position: absolute; */
/* top: -45px; */
right: 10px;
z-index: 1050;
display: none;
}
.btn-download-report {
/* position: absolute; */
top: 0;
right: 0;
z-index: 20;
}
}
.metric-report-page .metric-menu-trigger {
min-width: 2.375rem;
@ -42,15 +58,58 @@
.metric-report-page .metric-period-select {
width: auto;
max-width: 100%;
min-width: 9rem;
/* min-width: 9rem; */
font-size: 0.75rem;
background-position: right 0.35rem center;
}
.metric-report-page .metric-content-card {
border-radius: 0.35rem;
/* border-radius: 0.35rem; */
overflow: hidden;
}
/* Value / Trend capture roots — padding matches PNG export clone */
.metric-report-page .metric-value-png-capture,
.metric-report-page .metric-trend-png-capture {
padding: 1.5rem 1rem;
box-sizing: border-box;
border-radius: 0.35rem;
overflow: visible;
}
.metric-report-page .metric-chart-draw {
overflow: visible;
width: 100%;
}
.metric-report-page .metric-chart-draw svg {
display: block;
overflow: visible;
}
.metric-report-page .metric-chart-enter {
animation: metric-chart-enter 260ms ease-out both;
transform-origin: top center;
will-change: transform, opacity;
}
@keyframes metric-chart-enter {
from {
opacity: 0;
transform: translateY(8px) scale(0.99);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.metric-report-page .metric-chart-enter {
animation: none;
}
}
.metric-report-page .metric-subtab-row {
display: flex;
flex-wrap: nowrap;
@ -60,17 +119,19 @@
.metric-report-page .metric-subtab-btn {
border: none;
background: transparent;
padding: 0.5rem 1.25rem;
font-size: 0.875rem;
padding: 0.55rem 0.65rem;
font-size: 0.8rem;
white-space: nowrap;
color: #6c757d;
border-bottom: 2px solid transparent;
}
.metric-report-page .metric-subtab-btn.active {
color: var(--metric-accent);
/* color: var(--metric-accent); */
color: var(--color-secondary-dark);
font-weight: 600;
border-bottom-color: var(--metric-accent);
/* border-bottom-color: var(--metric-accent); */
border-bottom-color: var(--color-secondary);
}
.metric-report-page .metric-tile {
@ -110,8 +171,10 @@
}
.metric-report-page .metric-design-table thead th {
background-color: var(--metric-accent);
color: #fff;
/* background-color: var(--metric-accent); */
background-color: var(--color-secondary-lighter);
color:var(--color-secondary-dark);
/* color: #fff; */
font-size: 0.7rem;
font-weight: 600;
white-space: nowrap;
@ -139,6 +202,84 @@
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.04);
}
/*
* Leaderboard / My Team: freeze Rank + User.
* Rank column uses one fixed width; User sticky left MUST equal that width or a gap appears.
*/
.metric-report-page {
--metric-sticky-rank-col-w: 3.5rem;
--metric-sticky-tabular-col-1-w: 8.5rem;
}
.metric-report-page .metric-design-table th.metric-sticky-col-leader-1,
.metric-report-page .metric-design-table td.metric-sticky-col-leader-1 {
position: sticky;
left: 0;
z-index: 3;
box-sizing: border-box;
width: var(--metric-sticky-rank-col-w);
min-width: var(--metric-sticky-rank-col-w);
max-width: var(--metric-sticky-rank-col-w);
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.06);
}
.metric-report-page .metric-design-table thead th.metric-sticky-col-leader-1 {
z-index: 5;
background-color: var(--color-secondary-lighter);
}
.metric-report-page .metric-design-table th.metric-sticky-col-leader-2,
.metric-report-page .metric-design-table td.metric-sticky-col-leader-2 {
position: sticky;
left: var(--metric-sticky-rank-col-w);
z-index: 2;
box-sizing: border-box;
min-width: 6.5rem;
max-width: 32vw;
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.06);
}
.metric-report-page .metric-design-table thead th.metric-sticky-col-leader-2 {
z-index: 4;
background-color: var(--color-secondary-lighter);
}
/*
* Detail / Doctor List / Trend tabular: freeze first column when table is wide.
*/
.metric-report-page .metric-design-table th.metric-sticky-col-tab-1,
.metric-report-page .metric-design-table td.metric-sticky-col-tab-1 {
position: sticky;
left: 0;
z-index: 3;
box-sizing: border-box;
width: var(--metric-sticky-tabular-col-1-w);
min-width: var(--metric-sticky-tabular-col-1-w);
max-width: var(--metric-sticky-tabular-col-1-w);
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.06);
}
.metric-report-page .metric-design-table thead th.metric-sticky-col-tab-1 {
z-index: 5;
background-color: var(--color-secondary-lighter);
}
.metric-report-page .metric-design-table th.metric-sticky-col-tab-2,
.metric-report-page .metric-design-table td.metric-sticky-col-tab-2 {
position: sticky;
left: var(--metric-sticky-tabular-col-1-w);
z-index: 2;
box-sizing: border-box;
min-width: 6.5rem;
max-width: 28vw;
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.06);
}
.metric-report-page .metric-design-table thead th.metric-sticky-col-tab-2 {
z-index: 4;
background-color: var(--color-secondary-lighter);
}
.metric-report-page .metric-rank-badge {
display: inline-flex;
align-items: center;
@ -172,6 +313,15 @@
font-weight: 600;
}
.metric-report-page tr.metric-leaderboard-row--self td {
background-color: #fff3cd !important;
}
.metric-report-page tr.metric-leaderboard-row--self td.metric-sticky-col-leader-1,
.metric-report-page tr.metric-leaderboard-row--self td.metric-sticky-col-leader-2 {
background-color: #fff3cd !important;
}
.metric-report-page .metric-overlay-backdrop {
position: fixed;
inset: 0;
@ -213,12 +363,6 @@
background: #f8f9fa;
}
.metric-report-page .metric-level-pill--on {
background-color: var(--metric-accent) !important;
color: #fff !important;
border: none !important;
}
.metric-report-page .metric-tab-row {
display: flex;
flex-wrap: nowrap;
@ -236,7 +380,7 @@
.metric-report-page .metric-tab-btn {
border: none;
background: transparent;
padding: 0.65rem 0.85rem;
padding: 0.55rem 0.61rem;
font-size: 0.8rem;
white-space: nowrap;
color: #6c757d;
@ -245,9 +389,11 @@
}
.metric-report-page .metric-tab-btn.active {
color: var(--metric-accent);
/* color: var(--metric-accent); */
color: var(--color-secondary-dark);
font-weight: 600;
border-bottom-color: var(--metric-accent);
/* border-bottom-color: var(--metric-accent); */
border-bottom-color: var(--color-secondary);
}
.metric-report-page .metric-subchart-row .btn {
@ -285,9 +431,66 @@
}
.metric-report-page .metric-level-pill {
min-width: 2.5rem;
height: 2.5rem;
min-width: 2rem;
height: 2rem;
border-radius: 0.35rem;
font-weight: 600;
font-size: 0.85rem;
}
.metric-report-page .metric-level-pill--on {
/* background-color: var(--metric-accent) !important; */
background-color: var(--color-secondary-lighter) !important;
color: var(--color-secondary-dark) !important;
border: none !important;
}
.performance-stat-value-card {
position: relative;
}
.performance-stat-value-card__badges {
/* position: absolute; */
top: 0.5rem;
left: 0.5rem;
z-index: 2;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 0.35rem;
max-width: calc(100% - 0rem);
pointer-events: none;
padding: 0.75rem 0.5rem 0;
}
.badge.performance-stat-value-card__badge {
font-size: 0.7rem;
font-weight: 600;
padding: 0.25rem 0.45rem;
background-color: var(--color-yellow-lighter) !important;
color: var(--color-gray5) !important;
}
.badge.performance-stat-value-card__badge.bg-primary {
background-color: var(--color-primary-lighter1) !important;
}
.badge.performance-stat-value-card__badge.bg-secondary {
background-color: var(--color-secondary-lighter) !important;
}
.badge.performance-stat-value-card__badge.bg-warning {
background-color: var(--color-yellow-lighter) !important;
}
.badge.performance-stat-value-card__badge.bg-danger {
background-color: var(--color-red-lighter) !important;
}
.badge.performance-stat-value-card__badge.bg-success {
background-color: var(--color-green-lighter) !important;
}
.badge.performance-stat-value-card__badge.bg-light {
background-color: var(--color-gray-light6) !important;
}
.badge.performance-stat-value-card__badge.bg-transparent {
background-color: transparent !important;
}
.performance-stat-value-card__body--badged {
padding-top: 2rem !important;
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,129 @@
import React from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { Container, Card } from "react-bootstrap";
import { useQuery } from "@tanstack/react-query";
import PageComponent from "../components/PageComponent/PageComponent";
import PlaceholderCard from "../components/PlaceholderCard/PlaceholderCard";
import PerformanceStatValueCard from "../components/DashboardPerformanceStats/PerformanceStatValueCard";
import metricReportService from "../services/MetricReport";
import {
getMetricReportDefaultTab,
getMetricReportItemId,
getMetricReportItemTitle,
metricReportDetailPath,
parseMetricReportListResponse,
} from "../util/metricReportListUtil";
import "../pages/MetricReports.css";
function PerformanceStatsList() {
const navigate = useNavigate();
const location = useLocation();
React.useEffect(() => {
const legacyId = location.state?.metricViewId;
if (!legacyId) return;
navigate(metricReportDetailPath(legacyId), {
replace: true,
state: {
reportTitle: location.state?.reportTitle,
defaultTab: location.state?.defaultTab,
scoreboardMetricViewId: location.state?.scoreboardMetricViewId,
teamMetricViewId: location.state?.teamMetricViewId,
},
});
}, [location.state, navigate]);
const {
data: listItems,
isPending,
isFetching,
isError,
} = useQuery({
queryKey: ["metricReportList"],
queryFn: async () => {
const res = await metricReportService.getMetricReportList();
return parseMetricReportListResponse(res?.data);
},
});
const items = Array.isArray(listItems) ? listItems : [];
function handleOpenMetric(item) {
const metricViewId = getMetricReportItemId(item);
if (!metricViewId) return;
const title = getMetricReportItemTitle(item);
navigate(metricReportDetailPath(metricViewId), {
state: {
reportTitle: title,
defaultTab: getMetricReportDefaultTab(item),
},
});
}
if (isPending) {
return (
<PageComponent className="performance-stats-list-page">
<Container className="pt-1 pb-4">
<div className="d-grid gap-3">
{[0, 1, 2].map((i) => (
<PlaceholderCard
key={i}
type="details"
className="card-theme overflow-hidden"
/>
))}
</div>
</Container>
</PageComponent>
);
}
if (isError) {
return (
<PageComponent className="performance-stats-list-page">
<Container className="mb-4">
<Card className="card-theme border-0 shadow-sm p-3 rounded-3 font-14 text-center">
Could not load performance stats.
</Card>
</Container>
</PageComponent>
);
}
return (
<PageComponent className="performance-stats-list-page metric-report-page">
<Container className="metric-inner pt-1 pb-5">
{isFetching && items.length === 0 ? (
<div className="d-grid gap-3">
{[0, 1].map((i) => (
<PlaceholderCard
key={i}
type="details"
className="card-theme overflow-hidden"
/>
))}
</div>
) : items.length === 0 ? (
<Card className="card-theme border-0 shadow-sm p-3 rounded-3 font-14 text-center">
No performance stats available.
</Card>
) : (
<div className="d-grid gap-3 performance-stats-list-vertical">
{items.map((item, index) => {
const id = getMetricReportItemId(item) ?? `metric-${index}`;
return (
<PerformanceStatValueCard
key={id}
item={item}
onOpen={handleOpenMetric}
/>
);
})}
</div>
)}
</Container>
</PageComponent>
);
}
export default PerformanceStatsList;

View File

@ -0,0 +1,5 @@
export class ExportSaveWeb {
async saveTextToDownloads() {
throw new Error("ExportSave is not available in the browser");
}
}

View File

@ -0,0 +1,43 @@
import axios from "./Axios";
const contactServices = {
//Get Contact Approval Information
/** Sample data
* data = {
* "entity_id" : "1709189239-71379c1b-c509-4a63-ab1a-90fdb62c382b"
}
*/
getContactApprovalInfo: async (data) => {
console.log("Inside Service getContactApprovalInfo() ...", data);
return await axios.post("contact/contact/contactapproval", data);
},
//Approve Contact
/** Sample data
* data = {
* "submission_id" : "f2acc14c-d1e4-4609-b277-b5e72e0773da", (this is approval id but service accepts submission_id as a property)
"action" : "approve"
}
*/
approveContact: async (data) => {
console.log("Inside Service approveContact() ...", data);
return await axios.post("contact/contact/contactapprovereject", data);
},
//Reject Contact
/** Sample data
* data = {
* "submission_id" : "f2acc14c-d1e4-4609-b277-b5e72e0773da", (this is approval id but service accepts submission_id as a property)
"action" : "reject",
"reason" : "The value of prescriptions is not matching with sales data"
}
*/
rejectContact: async (data) => {
console.log("Inside Service rejectContact() ...", data);
return await axios.post("contact/contact/contactapprovereject", data);
},
};
export default contactServices;

View File

@ -53,6 +53,7 @@ const dashboardService = {
return await axios.get("metric/campaign_view/campaign_list");
},
//Dashboard Campaign Details
getCampaignDetails: async (id, period) => {
console.log("Inside Service getCampaignDetails() ...", id, period);
const qs = new URLSearchParams();
@ -61,6 +62,16 @@ const dashboardService = {
return await axios.get(`metric/campaign_view/campaign_details?${qs.toString()}`);
},
//Dashboard Campaign Metrics/Leaderboard
getCampaignMetrics: async (id, rank_type, period) => {
console.log("Inside Service getCampaignMetrics() ...", id, rank_type, period);
const qs = new URLSearchParams();
if (id) qs.set("id", id);
if (rank_type) qs.set("rank_type", rank_type);
if (period) qs.set("period", period);
return await axios.get(`metric/campaign_view/campaign_dashboard?${qs.toString()}`);
},
//Reloaded Dashboard User / Performance stats
getReloadedDashboardUserStats: async () => {
console.log("Inside Service getReloadedDashboardUserStats() ...");

View File

@ -36,6 +36,7 @@ const formServices = {
console.log("Inside Service approveForm() ...", data);
return await axios.post("form/formentity/approvereject", data);
},
};
export default formServices;

View File

@ -19,6 +19,11 @@ const metricReportService = {
postMetricReportTab: async (payload) => {
return await axios.post("metric/metric_report/view", payload);
},
/** Metric report list */
getMetricReportList: async () => {
return await axios.get("metric/metric_report/list");
},
};
export default metricReportService;

View File

@ -217,6 +217,7 @@ import {
FaAnglesRight,
FaAnglesLeft,
FaLocationDot,
FaDownload
} from "react-icons/fa6";
import { HiOutlineLightBulb, HiOutlinePhone, HiOutlineThumbUp, HiOutlineThumbDown } from "react-icons/hi";
import {
@ -882,6 +883,14 @@ const appIcons = [
familyCode: "io5",
component: <IoLocationSharp />,
},
{
title: "FaDownload",
category: "general",
tag: "download1",
familyName: "Font Awesome 6",
familyCode: "fa6",
component: <FaDownload />,
},
{
title: "FaLocationDot",
category: "file",

384
src/util/campaignHelpers.js Normal file
View File

@ -0,0 +1,384 @@
export function getCampaignId(campaign) {
if (!campaign) return null;
return campaign.id ?? campaign.campaign_id ?? campaign.campaignId ?? null;
}
export function campaignIdsMatch(campaign, id) {
if (!campaign || id == null || id === "") return false;
return String(getCampaignId(campaign)) === String(id);
}
export function readCampaignField(campaign, keys, fallback = "-") {
if (!campaign) return fallback;
for (const k of keys) {
const v = campaign[k];
if (v != null && v !== "") return v;
if (v === 0) return v;
}
return fallback;
}
/** Rank tiles on campaign card: show "-" when rank is 0 or missing. */
export function readCampaignRankDisplay(campaign, block) {
if (!campaign || !block?.valueKeys?.length) return "-";
for (const k of block.valueKeys) {
if (!(k in campaign)) continue;
const v = campaign[k];
if (v == null || v === "") continue;
const n = typeof v === "number" ? v : Number(v);
if (Number.isFinite(n) && n === 0) return "-";
return v;
}
return "-";
}
/** Normalize API color (hex without #, rgba, etc.) for use in CSS. */
export function normalizeCampaignColor(value) {
if (value == null || String(value).trim() === "") return null;
const s = String(value).trim();
if (/^(rgba?|rgb|hsla?|hsl|#)/i.test(s)) return s;
return `#${s.replace(/^#/, "")}`;
}
const CAMPAIGN_CARD_GRADIENT_ANGLE = "139deg";
/**
* Card background from campaign bg_color1 / bg_color2.
* Returns undefined when either color is missing — use `.dashboard-campaigns-card` CSS instead.
*/
export function getCampaignCardGradientStyle(campaign) {
if (!campaign) return undefined;
const color1 = normalizeCampaignColor(
campaign.bg_color1 ?? campaign.bgColor1
);
const color2 = normalizeCampaignColor(
campaign.bg_color2 ?? campaign.bgColor2
);
if (!color1 || !color2) return undefined;
return {
background: `linear-gradient(${CAMPAIGN_CARD_GRADIENT_ANGLE}, ${color1} 0%, ${color2} 100%)`,
};
}
/**
* When true, campaign rank tiles use fixed gradients/borders/icon styles below.
* Set to false to restore API `*_color` fields and bg-red / bg-orange / bg-green fallbacks.
*/
export const USE_STATIC_CAMPAIGN_RANK_STYLES = true;
const STATIC_CAMPAIGN_RANK_BLOCK_STYLES = {
overallRank: {
background: "linear-gradient(135deg, #fe1982d1 0%, #4e37665c 100%)",
color: "rgb(255, 255, 255)",
border: "1px solid #d55b96d1",
borderRight: "0px",
},
rankInTeam: {
background: "linear-gradient(45deg, #235db4c2 0%, #6497e4d9 100%)",
color: "rgb(255, 255, 255)",
border: "1px solid #5f90d870",
},
teamRank: {
background: "linear-gradient(45deg, #572f96cf 0%, #995eeb87 100%)",
color: "rgb(255, 255, 255)",
border: "1px solid #ae8ddd7d",
},
};
const STATIC_CAMPAIGN_RANK_ICON_STYLES = {
overallRank: {
background: "linear-gradient(140deg, rgb(225, 130, 206), rgb(226, 76, 204))",
},
rankInTeam: {
background: "linear-gradient(140deg, rgb(133, 170, 238), rgb(80, 137, 242))",
},
teamRank: {
background: "linear-gradient(140deg, rgb(154, 120, 228), rgb(138, 93, 228))",
},
};
/** Rank tiles on dashboard card and campaign details summary. */
export const CAMPAIGN_RANK_BLOCKS = [
{
rankType: "overallRank",
label: "Overall Rank",
valueKeys: ["overall_rank", "overallRank"],
colorKeys: ["overallRank_color", "overall_rank_color"],
bgClass: "bg-red",
iconTag: "crown1",
},
{
rankType: "rankInTeam",
label: "Rank in Team",
valueKeys: ["rank_in_team", "rankInTeam"],
colorKeys: ["rankInTeam_color", "rank_in_team_color"],
bgClass: "bg-orange",
iconTag: "network2",
},
{
rankType: "teamRank",
label: "Team Rank",
valueKeys: ["team_rank", "teamRank"],
colorKeys: ["teamRank_color", "team_rank_color"],
bgClass: "bg-green",
iconTag: "insights13",
},
];
export function getCampaignRankIconTag(block) {
return block?.iconTag ?? "crown1";
}
export function getCampaignRankTypeLabel(rankType) {
if (!rankType) return "Leaderboard";
const block = CAMPAIGN_RANK_BLOCKS.find((b) => b.rankType === rankType);
return block?.label ?? rankType;
}
function readCampaignColor(campaign, colorKeys) {
if (!campaign || !colorKeys?.length) return null;
for (const k of colorKeys) {
const normalized = normalizeCampaignColor(campaign[k]);
if (normalized) return normalized;
}
return null;
}
/**
* Crown icon wrapper behind rank value (dashboard campaign card).
*/
export function getCampaignRankIconStyle(block) {
if (!USE_STATIC_CAMPAIGN_RANK_STYLES || !block?.rankType) {
return null;
}
const iconBg = STATIC_CAMPAIGN_RANK_ICON_STYLES[block.rankType];
if (!iconBg) return null;
return {
width: 24,
height: 24,
...iconBg,
};
}
/**
* Rank block className + style from API color props, or fallback CSS class (bg-red, etc.).
*/
export function getCampaignRankBlockAppearance(campaign, block) {
const base = "extra-info-card";
if (USE_STATIC_CAMPAIGN_RANK_STYLES && block?.rankType) {
const staticStyle = STATIC_CAMPAIGN_RANK_BLOCK_STYLES[block.rankType];
if (staticStyle) {
return {
className: `${base} extra-info-card--static-rank`,
style: staticStyle,
};
}
}
const color = readCampaignColor(campaign, block.colorKeys);
if (color) {
return {
className: `${base} extra-info-card--theme-color`,
style: { background: color, color: "#fff" },
};
}
return {
className: `${base} ${block.bgClass}`,
style: undefined,
};
}
/**
* Normalizes campaign details API.
* Accepts axios body `{ statusCode, data: { campaign, metrics, periods } }`
* or the inner `{ campaign, metrics, periods }` payload.
*/
export function parseCampaignDetailsResponse(apiResponse) {
if (!apiResponse) {
return { campaign: {}, metrics: [], periods: [] };
}
let body = apiResponse;
if (
body?.data != null &&
typeof body.data === "object" &&
!Array.isArray(body.data) &&
(body.statusCode != null || body.status != null || body.message != null)
) {
body = body.data;
}
if (
body?.campaign != null ||
Array.isArray(body?.metrics) ||
Array.isArray(body?.periods)
) {
return {
campaign: body.campaign ?? {},
metrics: Array.isArray(body.metrics) ? body.metrics : [],
periods: Array.isArray(body.periods) ? body.periods : [],
};
}
const nested = apiResponse.data;
const legacyMetrics = Array.isArray(apiResponse.data)
? apiResponse.data
: Array.isArray(nested?.metrics)
? nested.metrics
: [];
return {
campaign: {
title: nested?.title ?? apiResponse.title,
icon: nested?.icon ?? apiResponse.icon,
bg_image: nested?.bg_image ?? apiResponse.bg_image,
default_period: nested?.default_period ?? apiResponse.default_period,
},
metrics: legacyMetrics,
periods: Array.isArray(nested?.periods) ? nested.periods : [],
};
}
export function dedupePeriodOptions(periods, fallback = []) {
const list =
Array.isArray(periods) && periods.length ? periods : fallback;
const seen = new Set();
return list.filter((p) => {
const v = p?.value;
if (v == null || v === "" || seen.has(v)) return false;
seen.add(v);
return true;
});
}
function readDateField(source, ...keys) {
if (!source || typeof source !== "object") return null;
for (const k of keys) {
const v = source[k];
if (v != null && String(v).trim() !== "") return String(v).trim();
}
return null;
}
/**
* Resolves display date range from campaign API payloads (details / dashboard / metric-style graph).
*/
export function extractCampaignDateRange(body, periodValue) {
if (!body || typeof body !== "object") {
return { firstDay: null, lastDay: null };
}
const graph = body.graph;
const campaign = body.campaign;
let firstDay =
readDateField(body, "first_day", "firstDay", "start_date", "startDate") ??
readDateField(graph, "first_day", "firstDay", "start_date", "startDate") ??
readDateField(campaign, "first_day", "firstDay", "start_date", "startDate");
let lastDay =
readDateField(body, "last_day", "lastDay", "end_date", "endDate") ??
readDateField(graph, "last_day", "lastDay", "end_date", "endDate") ??
readDateField(campaign, "last_day", "lastDay", "end_date", "endDate");
if (!firstDay || !lastDay) {
const periods = Array.isArray(body.periods) ? body.periods : [];
const match = periodValue
? periods.find((p) => p?.value === periodValue)
: null;
if (match) {
firstDay =
firstDay ??
readDateField(match, "first_day", "firstDay", "start_date", "startDate");
lastDay =
lastDay ??
readDateField(match, "last_day", "lastDay", "end_date", "endDate");
}
}
if (!firstDay || !lastDay) {
const periodObj = body.period;
firstDay =
firstDay ??
readDateField(periodObj, "first_day", "firstDay", "start_date", "startDate");
lastDay =
lastDay ??
readDateField(periodObj, "last_day", "lastDay", "end_date", "endDate");
}
return { firstDay, lastDay };
}
export const DEFAULT_CAMPAIGN_PERIODS = [
{ label: "May Month", value: "month-0" },
{ label: "April Month", value: "month-1" },
{ label: "Last 3 Months", value: "month-3" },
{ label: "Overall", value: "all" },
{ label: "This week", value: "week-0" },
{ label: "Last week", value: "week-1" },
{ label: "Today", value: "day-0" },
{ label: "Yesterday", value: "day-1" },
{ label: "Last 7 Days", value: "day-7" },
{ label: "Last 30 Days", value: "day-30" },
];
/** Normalizes campaign_dashboard API: { data: { campaign, response, periods, ... } }. */
export function parseCampaignDashboardResponse(apiResponse, periodValue) {
if (!apiResponse) {
return {
campaign: {},
response: [],
periods: [],
firstDay: null,
lastDay: null,
};
}
let body = apiResponse;
if (
body?.data != null &&
typeof body.data === "object" &&
!Array.isArray(body.data) &&
(body.statusCode != null || body.status != null || body.message != null)
) {
body = body.data;
}
const resolvedPeriod =
periodValue ??
body?.period ??
body?.campaign?.default_period ??
body?.campaign?.defaultPeriod;
const { firstDay, lastDay } = extractCampaignDateRange(body, resolvedPeriod);
if (body?.campaign != null || Array.isArray(body?.response)) {
return {
campaign: body.campaign ?? {},
response: Array.isArray(body.response) ? body.response : [],
periods: Array.isArray(body.periods) ? body.periods : [],
firstDay,
lastDay,
};
}
return {
campaign: {},
response: Array.isArray(body?.response) ? body.response : [],
periods: Array.isArray(body.periods) ? body.periods : [],
firstDay,
lastDay,
};
}
export function sumMetricPoints(metrics) {
if (!Array.isArray(metrics) || !metrics.length) return null;
return metrics.reduce((acc, m) => {
const value = Number(m?.value);
const weight = Number(m?.weight);
if (!Number.isFinite(value) || !Number.isFinite(weight)) return acc;
return acc + value * weight;
}, 0);
}

208
src/util/downloadBlob.js Normal file
View File

@ -0,0 +1,208 @@
import { Capacitor, registerPlugin } from "@capacitor/core";
import { Filesystem, Directory, Encoding } from "@capacitor/filesystem";
import { Share } from "@capacitor/share";
import { saveAs } from "file-saver";
import { toast } from "react-toastify";
import { handleAlert } from "./util";
const ExportSave = registerPlugin("ExportSave", {
web: () =>
import("../plugins/exportSave.web.js").then((m) => new m.ExportSaveWeb()),
});
const EXPORT_SUBDIR = "rapport-exports";
const DOWNLOADS_SAVED_MSG =
"Saved to Downloads — open the Files app and check the Downloads folder.";
function isCsvBlob(blob, filename) {
const n = String(filename || "").toLowerCase();
const m = String(blob?.type || "").toLowerCase();
return n.endsWith(".csv") || m.includes("csv");
}
function isPngBlob(blob, filename) {
const n = String(filename || "").toLowerCase();
const m = String(blob?.type || "").toLowerCase();
return n.endsWith(".png") || m.includes("png");
}
function sanitizeFilename(name) {
return (
String(name || "export")
.replace(/[/\\?%*:|"<>]/g, "-")
.replace(/\s+/g, "-")
.replace(/[^\w.\-]+/g, "-")
.replace(/^-+|-+$/g, "") || "export"
);
}
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const r = reader.result;
if (typeof r !== "string") {
reject(new Error("FileReader result was not a string"));
return;
}
const comma = r.indexOf(",");
resolve(comma >= 0 ? r.slice(comma + 1) : r);
};
reader.onerror = () => reject(reader.error || new Error("FileReader failed"));
reader.readAsDataURL(blob);
});
}
function waitForNextPaint() {
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
});
}
let exportToastId = null;
/** Show or update a non-dismissible loading toast during export (PNG capture, file write, share). */
export function showExportLoading(message) {
if (exportToastId != null) {
toast.update(exportToastId, { render: message, isLoading: true });
return;
}
exportToastId = toast.loading(message, {
closeButton: false,
autoClose: false,
});
}
export function hideExportLoading() {
if (exportToastId != null) {
toast.dismiss(exportToastId);
exportToastId = null;
}
}
export function isCapacitorNative() {
return Capacitor.isNativePlatform();
}
async function saveAndroidCsvToDownloads(blob, safe) {
const csvText = await blob.text();
await ExportSave.saveTextToDownloads({
filename: safe,
content: csvText,
mimeType: "text/csv",
});
}
/**
* Browser: file-saver.
* Android CSV: MediaStore text into Downloads.
* iOS / other native: cache + share sheet.
*/
export async function saveBlobWithDownloadUI(blob, filename) {
const safe = sanitizeFilename(filename);
const isNative = isCapacitorNative();
const isAndroid =
isNative && Capacitor.getPlatform() === "android";
const isCsv = isCsvBlob(blob, safe);
if (!isNative) {
saveAs(blob, safe);
return;
}
const isPng = isPngBlob(blob, safe);
showExportLoading(
isPng
? "Preparing file for download…"
: "Preparing export…"
);
try {
if (isAndroid && isCsv) {
try {
await saveAndroidCsvToDownloads(blob, safe);
hideExportLoading();
handleAlert(DOWNLOADS_SAVED_MSG, "success");
return;
} catch (e) {
console.warn("ExportSave CSV failed, falling back to share sheet", e);
}
}
showExportLoading("Opening share options…");
// Use the real export name on disk so iOS "Save to Files" keeps it (no timestamp prefix).
const path = `${EXPORT_SUBDIR}/${safe}`;
const mime = blob.type || "";
let writeResult;
if (mime.includes("csv") || mime.startsWith("text/")) {
const text = await blob.text();
writeResult = await Filesystem.writeFile({
path,
data: text,
directory: Directory.Cache,
encoding: Encoding.UTF8,
recursive: true,
});
} else {
const b64 = await blobToBase64(blob);
writeResult = await Filesystem.writeFile({
path,
data: b64,
directory: Directory.Cache,
recursive: true,
});
}
let fileUri = writeResult?.uri;
if (!fileUri) {
const { uri } = await Filesystem.getUri({
directory: Directory.Cache,
path,
});
fileUri = uri;
}
await shareNativeFile(fileUri, safe);
hideExportLoading();
} catch (e) {
hideExportLoading();
throw e;
}
}
async function shareNativeFile(uri, displayName) {
const opts = {
title: displayName || "Export",
dialogTitle: "Save or share file",
files: [uri],
};
// Do not set Share.text on iOS — it creates a separate "text N" file containing only the name.
try {
await Share.share(opts);
} catch (e) {
const msg = String(e?.message ?? e ?? "");
if (/cancel|dismiss|user/i.test(msg) || msg.includes("Share canceled")) {
return;
}
try {
const fallback = {
title: displayName || "Export",
url: uri,
dialogTitle: "Save or share file",
};
await Share.share(fallback);
} catch (e2) {
const msg2 = String(e2?.message ?? e2 ?? "");
if (/cancel|dismiss|user/i.test(msg2) || msg2.includes("Share canceled")) {
return;
}
throw e2;
}
}
}
export { waitForNextPaint, isPngBlob };

View File

@ -0,0 +1,104 @@
export const METRIC_REPORTS_LIST_PATH = "/metric-reports";
export function metricReportDetailPath(metricViewId) {
if (!metricViewId) return METRIC_REPORTS_LIST_PATH;
return `${METRIC_REPORTS_LIST_PATH}/${encodeURIComponent(metricViewId)}`;
}
export function parseMetricViewIdFromReportUrl(reportViewUrl) {
if (!reportViewUrl || typeof reportViewUrl !== "string") return null;
try {
const u = new URL(
reportViewUrl.startsWith("http")
? reportViewUrl
: `https://placeholder.local${reportViewUrl.startsWith("/") ? "" : "/"}${reportViewUrl}`
);
return u.searchParams.get("id");
} catch {
const m = String(reportViewUrl).match(/[?&]id=([^&]+)/);
return m ? decodeURIComponent(m[1]) : null;
}
}
function extractMetricsArray(payload) {
if (!payload || typeof payload !== "object") return null;
if (Array.isArray(payload)) return payload;
if (Array.isArray(payload.metrics)) return payload.metrics;
if (Array.isArray(payload.items)) return payload.items;
if (Array.isArray(payload.reports)) return payload.reports;
if (Array.isArray(payload.list)) return payload.list;
return null;
}
/** Normalize GET metric/metric_report/list response to an array of items. */
export function parseMetricReportListResponse(apiResponse) {
if (!apiResponse) return [];
const root = apiResponse;
const isSuccess =
root?.status === "success" ||
root?.statusCode === 200 ||
root?.statusCode === "200";
if (isSuccess && root.data != null) {
const fromData = extractMetricsArray(root.data);
if (fromData) return fromData;
}
const fromRoot = extractMetricsArray(root);
if (fromRoot) return fromRoot;
if (root?.data != null && typeof root.data === "object") {
const fromNested = extractMetricsArray(root.data);
if (fromNested) return fromNested;
}
return [];
}
/** Value-tab graph shape from a list API metric object. */
export function listItemToValueGraph(item) {
if (!item || typeof item !== "object") return null;
return {
name: item.name ?? item.label ?? "Value",
value: item.value,
values: Array.isArray(item.values) ? item.values : [],
};
}
export function getMetricReportItemId(item) {
if (!item || typeof item !== "object") return null;
return (
item.id ??
item.metric_view_id ??
item.metricViewId ??
parseMetricViewIdFromReportUrl(item.report_view_url)
);
}
export function getMetricReportItemTitle(item) {
if (!item || typeof item !== "object") return "Metric";
return String(
item.name ?? item.title ?? item.value_title ?? item.label ?? "Metric"
).trim();
}
export function getMetricReportDefaultTab(item) {
const aggType = String(item?.agg_type || "").toLowerCase();
if (aggType === "team") return "Detail";
if (aggType === "individual") return "Trend";
return "Detail";
}
export function getMetricReportCurrentPeriod(item) {
const period = item?.current_period;
if (period == null || String(period).trim() === "") return null;
return String(period).trim();
}
/** Human-readable label for list-card agg_type badge (e.g. team → Team). */
export function formatMetricReportAggTypeLabel(aggType) {
const raw = String(aggType || "").trim();
if (!raw) return null;
return raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase();
}

View File

@ -132,11 +132,15 @@ function getPageTitle(pageName) {
{ id: "appreciations", title: "Appreciations" },
{ id: "badges", title: "Badges" },
{ id: "rewards", title: "Rewards" },
{ id: "metric-reports", title: "Metric Reports" },
{ id: "metric-reports", title: "Performance Stats" },
{
id: "approve-reject-form-submission",
title: "Approve or Reject Form Submission",
},
{
id: "approve-reject-contact-submission",
title: "Approve or Reject Contact",
},
{ id: "quick-actions-content", title: "Quick Actions Content" },
{ id: "magazines", title: "Magazines" },
{ id: "magazine-details", title: "Magazine Details" },
@ -147,6 +151,7 @@ function getPageTitle(pageName) {
{ id: "upload-response", title: "Upload Response" },
{ id: "evaluate", title: "Evaluate" },
{ id: "competition-contests", title: "Contests" },
{ id: "campaigns", title: "Campaign Details" },
];
let pageTitle = "";

View File

@ -5,4 +5,8 @@ import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), nodePolyfills()],
// Pre-bundle table + export deps so dev server does not serve stale optimize chunks (504 Outdated Optimize Dep).
optimizeDeps: {
include: ["@tanstack/react-table", "file-saver"],
},
});