Added Metric list view, Metric details, Campaigns, Contact Approval/Reject Notification flow.
This commit is contained in:
parent
4b8f637794
commit
faaefaeb3b
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"appId": "com.stratawiz.rapportapp.Rapport",
|
||||
"appId": "com.stratawiz.mainrapport",
|
||||
"appName": "Rapport 2.0",
|
||||
"webDir": "dist",
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 |
41
src/App.jsx
41
src/App.jsx
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 "x" 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export function toMetricNumber(v) {
|
||||
const n = typeof v === "number" ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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`}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -42,6 +49,9 @@ const handleNotificationClick = (payload) => {
|
|||
case "form_reject":
|
||||
case "form_submitted":
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
} */
|
||||
|
|
@ -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}>
|
||||
|
||||
</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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -22,12 +22,28 @@
|
|||
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;
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export class ExportSaveWeb {
|
||||
async saveTextToDownloads() {
|
||||
throw new Error("ExportSave is not available in the browser");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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() ...");
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ const formServices = {
|
|||
console.log("Inside Service approveForm() ...", data);
|
||||
return await axios.post("form/formentity/approvereject", data);
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
export default formServices;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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 = "";
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue